每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。
游標小跟班
cursor跟著游標跑的小跟班。(´ ・ω・`)ノ╰(・ิω・ิ )
| 名稱 | 描述 |
|---|---|
| Floating UI | 用於定位浮動元素,例如:工具提示、彈出視窗、下拉選單等等 |
| Anime.js | 輕量級 JavaScript 動畫函式庫 |
| Pointer 事件 | 偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊 |
| 物理模擬 | 模擬真實世界物理現象,如重力、碰撞、速度等物理效果 |
| CSS 動畫 | 基於 CSS transition 和 animation 實現 |
| JS 動畫 | 基於 JavaScript 實現的動畫,達成更複雜、精準的動畫控制,常見套件有 GSAP、anime.js 等 |
| SVG Morph | 讓 SVG 在不同形狀間平滑過渡的技術 |
| Document activeElement | 取得目前擁有焦點的元素 |
| Document elementFromPoint() | 取得指定座標位置最上層的元素 |
| Document getSelection() | 取得使用者目前選取的文字內容或游標位置 |
平時跟著游標跑,碰到特定元素會有特殊互動
<template> <div class="w-full flex flex-col gap-4 example-wrap"> <cursor-sidekick v-if="enable" />
<base-checkbox v-model="enable" :label="t('enableSidekick')" class="example-ctrl" />
<div class="flex flex-col gap-2"> <div class="text-2xl font-bold"> {{ t('title') }} </div>
<div> {{ t('selectText') }} </div>
<hr>
<base-input v-model="text" :label="t('textInput')" /> <div class="h-[20vh] w-1/2 border rounded"> <textarea v-model="text" :placeholder="t('multilineText')" class="h-full w-full p-2" /> </div>
<div contenteditable class="w-2/3 border rounded p-2" > {{ t('editableDiv') }} </div>
<hr>
<base-btn :label="t('button')" /> <base-btn disabled :label="t('naughtyBtn')" />
<hr>
<a href="https://codlin.me/" target="_blank" > {{ t('codlinBlog') }} </a>
<img src="/low/painting-codfish-bakery.webp" :alt="t('greedyCodfish')" > </div> </div></template>
<script setup lang="ts">import { ref } from 'vue'import { useI18n } from 'vue-i18n'import BaseBtn from '../../base-btn.vue'import BaseCheckbox from '../../base-checkbox.vue'import BaseInput from '../../base-input.vue'import CursorSidekick from '../cursor-sidekick.vue'
const enable = ref(false)const text = ref('')
const { t } = useI18n()</script>自行設計 Provider,產生各種奇奇怪怪的互動吧!ლ(´∀`ლ)
嘗試反白這段文字中有鱈魚的部分和沒有鱈魚的部分,看看有甚麼差別。

<template> <div class="w-full flex flex-col gap-4 example-wrap"> <cursor-sidekick v-if="enable" color="#35abf0" :hover-providers="hoverProviderList" :select-providers="selectProviderList" />
<base-checkbox v-model="enable" :label="t('enableSidekick')" class="example-ctrl" />
<div class="flex flex-col gap-2"> <div> {{ t('selectHint') }} </div>
<hr>
<base-btn disabled :label="t('naughtyBtn')" />
<hr>
<img src="/low/photography-ears-of-rice.webp" url="https://www.flickr.com/photos/coodfish/albums/" alt="稻穗攝影作品" > </div> </div></template>
<script setup lang="ts">import type { ContentProvider } from '../use-content-provider'import { computed, ref } from 'vue'import { useI18n } from 'vue-i18n'import BaseBtn from '../../base-btn.vue'import BaseCheckbox from '../../base-checkbox.vue'import CursorSidekick from '../cursor-sidekick.vue'
const enable = ref(false)
const { t } = useI18n()
const hoverProviderList = computed<ContentProvider[]>(() => [ { match(data) { if ('rect' in data) return false
if ( !(data instanceof HTMLButtonElement) && data?.getAttribute('role') !== 'button' ) { return false }
return data.innerHTML.includes('色色') || data.innerHTML.includes('naughty') }, getContent: () => ({ btnList: [ { label: t('naughtyPortal'), onClick() { window.open( 'https://raw.githubusercontent.com/tpai/dogedeck/main/cards/%E6%8A%97%E8%89%B2%E8%89%B2%E8%97%A5.png', '_blank', ) }, }, ], }), },
{ match(data) { if ('rect' in data) return false
if (data instanceof HTMLImageElement) { return true }
return false }, getContent(param) { const { element } = param const target = element?.value
if (!(target instanceof HTMLImageElement)) return
const url = target.getAttribute('url') if (!url) return
return { btnList: [ { label: t('viewMorePhotos'), onClick() { window.open(url ?? '', '_blank') }, }, ], } }, },])
const selectProviderList = computed<ContentProvider[]>(() => [ { match(data) { if (!('rect' in data)) return false
return data.text.includes('鱈魚') || data.text.includes('Codfish') }, getContent: () => ({ text: `${t('foundYouLine1')}<br>${t('foundYouLine2')}`, class: ' text-nowrap ', btnList: [ { label: '🎬 Youtube', onClick() { window.open('https://www.youtube.com/@codfish2140', '_blank') }, }, { label: '💡 CodePen', onClick() { window.open('https://codepen.io/Codfish2140', '_blank') }, }, { label: t('codlinBlog'), onClick() { window.open('https://codlin.me/', '_blank') }, }, ], }), },])</script>這個小廢物元件應該是使用最多種瀏覽器 API 的元件了,以下是 API 與其應用範圍:
-
Document: activeElement property :取得目前 focus 的輸入框元素。
-
Document: elementFromPoint() method :取得目前 hover 中的元素。
-
Document: getSelection() method :取得選取文字與位置。
-
Element: getBoundingClientRect() method :取得目標元素尺寸與位置。
小跟班的變形動畫是靠老朋友 anime.js 實現。
tooltip 定位使用 Floating UI 實現,功能超強大,極度推薦。
interface Props { /** 單位 px */ size?: number; /** \# 前綴之 HEX 格式 * @default '#515151' */ color?: string; /** 最大速度。越慢小跟班越悠哉。單位 px/ms * @default 1 */ maxVelocity?: number; /** @default 100 */ zIndex?: number;
/** 匹配 active element 的 provider。 * * 通常用於可點擊或 focus 的元素。 */ activeProviders?: ContentProvider[];
/** 匹配 hover element 的 provider * * 只要 hover 到符合條件的元素,即會觸發。 */ hoverProviders?: ContentProvider[];
/** 匹配選取文字的 provider */ selectProviders?: ContentProvider[];}ContentProvider 定義如下:
interface BtnOption { label: string; onClick: (event?: Event) => void;}
interface Content { /** 用於調整內容樣式 */ class?: string; text?: string; /** 按鈕清單 */ btnList?: BtnOption[]; /** 預覽連結內容 */ preview?: { src: string; class: string; };}
export interface ContentProvider { /** 判斷目前元素或文字是否符合 */ match: ( data: HTMLElement | SelectionState ) => boolean; /** 取得小跟班顯示用內容 */ getContent: ( param: TargetParam ) => Content | undefined;}