Skip to content

七彩殘影包裝器 wrapper

抓著甩、捲一捲,包起來的內容都會在畫面上留下五彩繽紛的殘影。ᕕ( ゚ ∀。)ᕗ

技術關鍵字

名稱描述
DOM to Image將 DOM 元素轉換為圖片的技術,基於 SVG foreignObject 實現
Canvas 2D API基礎的 2D 繪圖 API,可以高效繪製比 DOM 更複雜的圖形
Canvas Filter在 Canvas 上直接套用 CSS 濾鏡,例如模糊、色相旋轉
Canvas 合成模式控制新圖形與畫布既有內容的疊合方式,例如相加、只塗在既有像素上
CSS mix-blend-mode指定元素與底下畫面的混色方式,例如相加發光的 plus-lighter、壓暗的 multiply
Element getBoundingClientRect()取得元素相對視窗的座標與尺寸
Pointer 事件偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊
彈簧阻尼系統以勁度與阻尼常數模擬彈簧的震盪與回彈,常用於自然的 UI 動態回饋
IntersectionObserver偵測元素是否進入或離開視窗

使用範例

基本用法

捲動頁面、抓著甩動都會留下殘影,放手後元素會彈回原位。

鱈魚
一隻熱愛程式的魚,但是沒有手指可以打鍵盤,更買不到能在水裡用的電腦。(´;ω;`)
查看範例原始碼
vue
<template>
  <div class="example-wrap w-full flex flex-col items-center gap-10 py-10">
    <wrapper-tracers :ref="echoRefList.set">
      <img
        src="/low/profile.webp"
        alt=""
        class="w-40 border-[0.25rem] rounded-full object-cover"
      >
    </wrapper-tracers>

    <wrapper-tracers :ref="echoRefList.set">
      <div class="text-center text-3xl font-bold">
        {{ t('codfish') }}
      </div>
    </wrapper-tracers>

    <wrapper-tracers :ref="echoRefList.set">
      <div class="card border rounded p-6">
        <div class="mt-2 max-w-[17rem]">
          {{ t('codfishDescription') }}
        </div>
      </div>
    </wrapper-tracers>
  </div>
</template>

<script setup lang="ts">
import { useTemplateRefsList } from '@vueuse/core'
import { useData } from 'vitepress'
import { watch } from 'vue'
import { useI18n } from 'vue-i18n'
import WrapperTracers from '../wrapper-tracers.vue'

const { t } = useI18n()
const data = useData()

const echoRefList = useTemplateRefsList<
  InstanceType<typeof WrapperTracers>
>()

/** 深色模式切換後配色會變,需重新擷取內容 */
watch(() => data.isDark.value, () => {
  echoRefList.value.forEach((echoRef) => {
    echoRef.refresh()
  })
})
</script>

<style scoped lang="sass">
.card
  background: light-dark(#EEE, #333)
</style>

原理

先拍一張照片

殘影要跟內容長得一樣,最省事就是直接拍照。snapdom 把插槽內容轉成畫布,後面每一張殘影都從這張畫布複製。原始 DOM 照常顯示、照常互動,殘影只是疊在後面的裝飾。

拍回來的畫布不保證帶著 CSS 圓角,圓形頭像很容易拍成方形照片。因此擷取後會照元素外框補裁一次,用 even-odd 填出方角減圓角的那幾塊,再以 destination-out 挖掉。圓角底下若有人畫背景,那圈像素本來就該露出背景,這種就跳過不挖,否則會在卡片上開洞。

殘影是元素過去的位置

捲動時元素在文件裡沒有移動,移動的是視窗。殘影要留在原地,所以記下元素過去的視窗座標,跟目前的一減,差值就是該畫的位移。

window.scrollY 只認得整頁捲動。改成每幀讀一次 getBoundingClientRect(),容器捲動、sticky、元素自己跑動畫,通通都留得下殘影。量的是還沒變形的外層,再自己加上拖曳位移,直接量變形後的元素,擠壓會混進座標讓殘影發抖。

畫布用 position: fixed 鋪滿視窗。固定定位不佔版面,撐不出捲軸,也不受祖先 overflow: hidden 裁切,getBoundingClientRect() 拿到的座標還能直接當繪圖座標。

留著慢慢揮發

畫布不會每幀清空,改成每幀淡一點,捲動與拖曳於是變成在頁面上光繪。淡化用 destination-out 扣掉既有像素的 alpha,蓋一層半透明色塊會把殘影整片染色,換個深淺背景就破功。舊殘影自己留在畫布上,每幀只需要補上新走的那一小段,不必重畫整條尾巴。

揮發要填多大一塊,直接決定會不會掉幀。第一版拿所有上過色的範圍去填,那塊只增不減,捲一陣子就等於整個視窗。改成記下每幀上色的區塊與時間,只聯集還在 trailDecay 之內的那幾塊,填色範圍就縮成尾巴本身。

元素捲出畫面就停掉 requestAnimationFrame,回來時清掉畫布與上一次的座標,否則離開期間的位移會在第一幀畫成一條橫跨整頁的直線。

沿路徑等距蓋章

一幀蓋一張最直覺,尾巴斷成一格一格也出在這裡。張數固定,移動越快間距拉得越開,看起來像一排印章。改以距離決定張數就連得起來,相鄰殘影固定相隔 stampGap,這一幀走得越遠就沿路多蓋幾張,maxStampCount 當煞車,到頂之後間距才放寬。

一幀蓋一張固定距離蓋一張

蓋得密,同一個位置連續好幾幀都會再蓋上一張,單張直接用目標濃度會疊成一片實心色塊。n 層透明度 a 疊起來得到 1 - (1 - a)^n,反解回去就知道單張該用多淡。

這個算法還漏了一件事,疊加期間殘影一直在揮發。這幾張分散在好幾幀,等最後一張蓋上去,最早那幾張早就淡掉了。把層數當成同時存在會嚴重高估,補償過頭,尾巴就淡到看不清楚,移動越慢差距越大。元素走完自己身長需要 身長 / 速度 這麼久,這段期間的衰減積分得到小於一的係數,乘回層數才是實際還疊得起來的張數。

換掉顏色

白色卡片、灰階照片本身沒有彩度,色相轉一圈還是灰的,所以顏色要整個換掉。colorMode 有三種換法。

漸層映射是預設,用亮度查一張 256 階色帶,亮的走尾端、暗的走開頭。平塗把整塊蓋成同一個顏色,明暗跟著消失;查表則把原本的明暗重新對應到色帶上,照片的立體感、卡片的陰影都留得下來。映射完彩度已經夠,各色階直接用 hue-rotate() 旋轉。色帶負責用亮度換色相,不該拿來換亮度,所以每個顏色的亮度要接近而且都夠高,開頭放了深色的話,深色文字查過去仍然是深色。

RGB 色散把殘影拆成紅、綠、藍三張單通道畫布,沿運動方向錯開再相加。三個通道相加會還原成原色,只有錯開處留下彩色邊。錯開方向要跟著運動方向跑,方向卻是執行時才知道,因此改以方向分階,每一階先烘好一張,畫的時候查表,維持一次 drawImage

純色平塗source-atop 疊一層純色,只塗在已經有像素的地方,色相依走過的距離換。

前兩種都要讀像素,跨網域圖片會汙染畫布讓 getImageData 丟例外,這時退回原色或單純的模糊殘影。glow 打開之後殘影改用 lighter 相加,密的地方爆白成霓虹,濃度補償也換成除以重疊層數。

抓著甩,跟著變形

只能捲動的話,互動就很被動,使用者為了讀內容才捲,殘影只是副產品。抓得起來就不一樣,軌跡完全由手決定。

按下先記起點,位移超過四像素才算拖曳,單純點擊不會被吃掉。pointermove 掛在 window 上,手指跑出元素也追得到;換成 Pointer Capture 也追得到,但那會從按下就接管命中測試,包在裡面的文字連選都選不起來。放手後跑彈簧阻尼,加速度是 -k * 位移 - c * 速度,逐幀積分回原位。

按著不放不能出現反白,所以直接擋掉 selectstart,拖曳期間整份文件也禁用選取。瀏覽器本來就能把圖片與連結拖走,dragstart 一併擋掉,否則原生拖放會把手勢接走。觸控只攔水平方向,垂直留給頁面捲動。

移動時元素本身沿運動方向拉長、垂直方向壓扁,也就是動畫的 squash and stretch。任意角度都要能正確擠壓,所以先轉到運動方向縮放,再轉回來。

定點在中央前後一起往外長定點在前緣前緣不動,身體往後拉長↓ 運動方向

變形的定點不放正中央。從中央拉伸前後會同時往外長,看起來像整塊在呼吸;定點移到運動方向的前緣,前緣咬住不動、身體往後拉長,才有被拖著走的感覺。拖曳時定點再改到抓取點,指下那一點才不會滑掉。逐幀速度會抖,先做一次阻尼平滑,順帶讓手停下來之後還有一點餘韻。

原始碼

API

Props

/** 畫布與底下內容的混合模式 */
type EchoBlendMode = 'normal' | 'screen' | 'plus-lighter' | 'lighten' | 'overlay' | 'difference'

/** 殘影的上色方式 */
type EchoColorMode = 'gradient' | 'chromatic' | 'rainbow'

interface Props {
  /**
   * 關閉後只剩原本的內容,不留任何殘影。
   *
   * @default true
   */
  enabled?: boolean;
  /**
   * 可否用滑鼠或手指抓著甩。
   *
   * 放手後依甩動速度做慣性,再以彈簧回到原位,沿途自然拉出一整條殘影。
   * 觸控只攔截水平方向,垂直留給頁面捲動。
   * 從輸入框、按鈕、連結上按下的手勢不會被攔截。
   *
   * @default true
   */
  draggable?: boolean;
  /**
   * 殘影揮發所需的時間(ms)。
   *
   * 畫布不會每幀清空,而是每幀淡一點,捲動與拖曳因此像在頁面上光繪。
   * 調短就回到一般的短尾巴,調長則痕跡留得久,畫面越玩越濃。
   *
   * @default 900
   */
  trailDecay?: number;
  /**
   * 相鄰兩張殘影的間距(px)。
   *
   * 這個值決定尾巴連不連續。移動越快,元件就在這一幀的路徑上多蓋幾張,
   * 讓相鄰殘影始終保持這個間距,不會拉開成一格一格的印章。
   *
   * @default 6
   */
  stampGap?: number;
  /**
   * 單幀最多疊幾張殘影。
   *
   * 甩動速度沒有上限,張數必須有,否則一次甩出去就得畫上百張全尺寸的圖。
   * 到達上限後間距會自動放寬。
   *
   * @default 32
   */
  maxStampCount?: number;
  /**
   * 上色方式。
   *
   * - `gradient`:用亮度查一張漸層,保留內容原本的明暗層次,出來是油光質感。
   *   白色卡片、灰階照片也照樣出得了顏色。
   * - `chromatic`:把殘影拆成紅綠藍三份,沿運動方向錯開再相加。
   *   重合處回到原色,岔開處留下彩色邊,也就是色散。
   * - `rainbow`:整片染成純色再依距離換色相,最單純但也最平。
   *
   * @default 'gradient'
   */
  colorMode?: EchoColorMode;
  /**
   * `gradient` 模式的色帶,至少兩個顏色,接受任何 CSS 色彩字串。
   *
   * 亮度零對應第一個顏色,全亮對應最後一個,中間線性內插。
   *
   * 色帶負責用亮度換色相,不該拿來換亮度,因此每個顏色的亮度要接近而且都夠高。
   * 開頭若放了深色,深色文字查過去仍然是深色,糊開之後看起來就是一團黑。
   *
   * @default ['#FF6FB5', '#FF9F45', '#FFE85C', '#7CFFB2', '#6FD4FF']
   */
  gradient?: string[];
  /**
   * `chromatic` 模式的通道錯開距離(px)。
   *
   * 調大彩色邊越寬,過大則三個通道各走各的,看不出原本的形狀。
   *
   * @default 4
   */
  chromaticOffset?: number;
  /**
   * 預先算好的離屏畫布張數。
   *
   * `gradient` 與 `rainbow` 模式下是色階數,決定尾巴的顏色細緻度;
   * `chromatic` 模式下則是方向階數,決定色散的方向能跟得多準。
   *
   * 不影響殘影張數。記憶體與這個值成正比,包很大一塊內容時建議調低。
   *
   * @default 8
   */
  colorCount?: number;
  /**
   * 相鄰兩個色階的色相差(deg)。
   *
   * 360 除以色階數量可讓尾巴剛好繞完一圈色環,調小則整條尾巴偏同一個色系。
   *
   * @default 45
   */
  hueStep?: number;
  /**
   * 走多遠換完一輪色階(px)。
   *
   * 顏色跟著走過的距離跑,而非跟著時間,因此甩得快慢不影響配色,
   * 只有距離會。調小則色帶密,調大則整條尾巴同色系。
   *
   * @default 240
   */
  hueCycleDistance?: number;
  /**
   * 上色強度,0 為完全保留原色、1 為整片換成指定的顏色。
   *
   * 直接旋轉原色的色相看似最自然,但白色卡片、灰階圖片轉不出顏色,
   * 尾巴只會是一團灰,因此改成把顏色換掉。
   * `chromatic` 模式用不到這個值,色散本來就從原色拆出來。
   *
   * `gradient` 模式建議留在接近 1,色帶本身已經帶著明暗層次,
   * 混回原色只會把彩度稀釋掉。`rainbow` 模式則相反,1 會變成純色剪影。
   *
   * @default 0.95
   */
  tint?: number;
  /**
   * 最舊那個色階的模糊半徑(px),中間各階依序遞增。
   *
   * 模糊在預先上色時就算完,每幀不必重跑,因此調大不影響移動時的效能,
   * 只會讓離屏畫布往外多撐 `blur * 3` 的邊界。
   *
   * 調大會把同樣的墨量攤到更大的面積上,尾巴跟著變淡,
   * 覺得看不清楚時這裡通常比 `opacity` 更有效。
   *
   * @default 6
   */
  blur?: number;
  /**
   * 剛畫上去那一段的尾巴濃度。
   *
   * 這是疊完之後的濃度,不是單張殘影的透明度,
   * 因此調整 `stampGap`、`trailDecay` 或移動速度,都不會讓尾巴跟著變濃或變淡。
   *
   * @default 0.85
   */
  opacity?: number;
  /**
   * 殘影之間改用相加混合,密的地方會累加爆白成霓虹。
   *
   * 深色底才看得出效果,淺色底上相加只會整片洗白,
   * 這時可搭配 `blendMode` 一起調整。
   *
   * @default false
   */
  glow?: boolean;
  /**
   * 移動時的擠壓拉伸強度,0 為關閉。
   *
   * 借用動畫的 squash and stretch,沿運動方向拉長、垂直方向壓扁,
   * 速度慢下來就彈回原形。速度先用阻尼平滑過,才不會每幀抖動。
   *
   * @default 0.35
   */
  stretch?: number;
  /**
   * 畫布的解析度倍率,1 代表一個畫布像素對一個 CSS 像素。
   *
   * 畫布跟視窗一樣大,跟著裝置像素比走的話,
   * 高解析螢幕每幀要重新處理的像素會多出四倍,這是整個元件最貴的一筆。
   * 殘影本來就是模糊的裝飾,1 倍看不出差別,想要更銳利再往上調。
   *
   * @default 1
   */
  resolution?: number;
  /**
   * 畫布疊在底下內容之上的混合模式。
   *
   * 深色底改用 `screen` 或 `plus-lighter`,殘影會像發光一樣浮起來。
   *
   * @default 'normal'
   */
  blendMode?: EchoBlendMode;
}

Methods

interface Expose {
  /** 重新擷取內容並重建殘影,內容或主題變更後呼叫 */
  refresh: () => Promise<void>;
  /** 立刻抹掉畫布上所有殘影 */
  clear: () => void;
}

Slots

interface Slots {
  /** 要留下殘影的內容 */
  default?: () => unknown;
}

v0.81.0