Skip to content

酸檸檬游標 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="{ ... }" 標記要凹陷的元素。游標碰到標記元素即向內凹陷。

酸到凹掉 (◞✱◟ )
查看範例原始碼
vue
<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 監看尺寸變化重算),並以 pointerenterpointerleave 偵測檸檬是否碰到。所有元素共用一條 rAF 彈簧迴圈,收斂即停。
  • 游標:以 VueUse 的 useMouse 取得座標、useMousePressed 偵測按壓,再 teleport 手繪 SVG 至游標位置;掛載時開啟全域啟用旗標並隱藏原生游標,卸載還原。唯有游標掛載、且檸檬碰到標記元素才凹陷。

凹陷與摺痕陰影出自同一個 SVG filter graph,位移與陰影各司其職,位移只管乾淨的徑向收縮,摺痕只在陰影現形。整體分四步:

  1. 用 Canvas 預先產生兩張表面圖:位移圖(R、G 通道編碼朝外的徑向位移)與陰影圖(灰階明暗,放射狀摺痕化作乾淨暗線)。兩張圖僅在尺寸或參數變化時重算。
  2. feDisplacementMap 依位移圖取樣外側內容,畫面朝中心聚攏,形成往中心吸陷的凹痕。同樣手法見於魚花
  3. feColorMatrix 依當前凹陷量在「白(無效果)↔ 完整陰影」間插值(out = depth·shading + (1 − depth)),動畫時逐格只改矩陣值,成本極低。
  4. feComposite operator="arithmetic"k1=1)讓陰影相乘疊上收縮後的內容,摺痕暗線落在凹陷表面上。

陰影採遮蔽式暗化而非方向光。方向光打在放射脊上會變成半亮半暗的風車,不像摺痕。摺痕本質是凹槽的陰影,不論光從哪來槽底都暗,故直接依摺痕深度將凹槽壓成均勻暗線,細窄摺痕即細窄暗線。摺痕線帶隨機角度、深淺、粗細,以元素固定的種子產生,縮放重算都不亂跳,並在中心與邊緣淡出。

位移採有界徑向收縮,取樣半徑 σ(ρ) = range · t^at = ρ/range)恆 ≤ range,位移量則為 σ − ρ,邊界處(t=1)為原樣。因此只收縮範圍內的內容、絕不取樣到元素外部,外側透明區也不致捲進中心。

收縮指數為 a = 1 − intensity^(1/falloff)intensity=0a=1(原樣),intensity=1a=0,範圍內每個點都取樣到邊界,內容收乾後完全消失(留白內容則收成透明)。range 控制收縮半徑、falloff 控制多快逼近完全收縮。

檸檬碰到元素時 pointerenter 回報,再以彈簧動畫同步拉高 feDisplacementMapscale 與陰影插值的凹陷量,讓元素酸到向內凹陷,離開則 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;
}

v0.66.0