Skip to content

拼圖按鈕 button

拚完所有拼圖才能按的按鈕 ◝( ゚ ∀ ゚ )◟

技術關鍵字

名稱 描述
Pointer 事件偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊
SVG MaskSVG 遮罩效果,用於控制元素的顯示區域,可以實現複雜的形狀切割和遮罩
JS 動畫基於 JavaScript 實現的動畫,達成更複雜、精準的動畫控制,常見套件有 GSAP、anime.js 等

使用範例

基本用法

拖動碎片,完成拼圖吧

查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col gap-4 border border-gray-200 rounded-xl p-6">
    <div class="flex justify-center">
      <btn-jigsaw-puzzle
        :label="t('clickMe')"
        @click="handleClick"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import BtnJigsawPuzzle from '../btn-jigsaw-puzzle.vue'

const { t } = useI18n()

function handleClick() {
  // eslint-disable-next-line no-alert
  alert(t('congratulations'))
}
</script>

表單範例

阻擋機器人和討厭的客訴吧!(・∀・)9(欸?

客訴回饋單
若您有任何疑慮,請在此填寫您的意見,我們將會在第一時間處理您的問題。
為防止濫用,完成拼圖後才能送出 (*´∀`)~♥
查看範例原始碼
vue
<template>
  <div class="relative w-full flex flex-col gap-10 border border-gray-200 rounded-xl p-6">
    <div class="flex flex-col gap-3">
      <div class="text-3xl font-bold">
        {{ t('complaintFeedbackForm') }}
      </div>
      <div class="text-sm text-gray-500">
        {{ t('complaintFeedbackFormTip') }}
      </div>

      <div class="flex justify-center border border-gray-200 rounded-lg p-3">
        <textarea
          v-model="text"
          :placeholder="t('inputPlaceholder')"
          class="min-h-[30vh] w-full"
        />
      </div>

      <div class="w-full flex justify-center">
        <btn-jigsaw-puzzle
          ref="jigsawPuzzleRef"
          :row-count="3"
          :col-count="4"
          @click="handleSubmit"
        >
          <button class="btn w-full select-none rounded p-3 px-20 text-3xl">
            {{ t('submitBtn') }}
          </button>
        </btn-jigsaw-puzzle>
      </div>

      <div class="mt-2 text-center text-xs text-gray-500">
        {{ t('jigsawPuzzleTip') }}
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import BtnJigsawPuzzle from '../btn-jigsaw-puzzle.vue'

const { t } = useI18n()

const jigsawPuzzleRef = useTemplateRef('jigsawPuzzleRef')

const text = ref('')
function handleSubmit() {
  // eslint-disable-next-line no-alert
  alert(t('thanks'))

  text.value = ''
  jigsawPuzzleRef.value?.scatter()
}
</script>

<style scoped lang="sass">
.btn
  background-color: light-dark(#444, #EEE)
  color: light-dark(#EEE, #444)
  transition-duration: 0.2s
  &:active
    transition-duration: 0.1s
    transform: scale(0.98)
</style>

防止搶票

防止壞壞的搶票機器人吧!(⌐■_■)✧

選擇「鱈魚亞洲演唱會」門票:
時間:2030/02/30 03:00
地點:台灣海峽 - 24°19'22.3"N 119°58'43.3"E
為防止濫用,請在 4 秒內完成拼圖後才能點擊送出 (*´∀`)~♥

路人:「這連真人都防住啦!ლ(´口`ლ)」

查看範例原始碼
vue
<template>
  <div class="relative w-full flex flex-col gap-10 border border-gray-200 rounded-xl p-6">
    <div class="flex flex-col gap-3">
      <div class="max-w-xl w-full flex flex-col gap-3">
        <div class="mb-1 text-lg font-semibold">
          選擇「鱈魚亞洲演唱會」門票:
        </div>

        <div class="text-sm text-gray-500">
          時間:2030/02/30 03:00<br>
          地點:台灣海峽 - 24°19'22.3"N 119°58'43.3"E
        </div>

        <div class="grid grid-cols-1 gap-3">
          <label
            v-for="ticket in ticketOptions"
            :key="ticket.id"
            class="cursor-pointer"
          >
            <input
              v-model="selectedTicketId"
              type="radio"
              class="peer hidden"
              :value="ticket.id"
            >

            <div
              class="w-full flex flex-col gap-1 border rounded-lg px-4 py-3 text-sm transition-all hover:border-blue-400 peer-checked:border-blue-500 peer-checked:bg-blue-50"
            >
              <div class="flex items-center justify-between">
                <div class="text-lg text-gray-500 font-black">
                  {{ ticket.area }}
                </div>
                <div class="text-base font-bold">
                  NT$ {{ ticket.price.toLocaleString() }}
                </div>
              </div>

              <div
                v-if="ticket.note"
                class="text-xs text-amber-600"
              >
                {{ ticket.note }}
              </div>
            </div>
          </label>
        </div>
      </div>

      <div class="py-2 text-center text-gray-500">
        為防止濫用,請在 {{ MAX_TIMER_COUNT }} 秒內完成拼圖後才能點擊送出 (*´∀`)~♥
      </div>

      <div
        class="w-full flex justify-center duration-300"
        :class="{ 'opacity-50 pointer-events-none': !selectedTicketId }"
      >
        <btn-jigsaw-puzzle
          ref="jigsawPuzzleRef"
          @drag-start="handleDragStart"
          @click="handleSubmit"
        >
          <button class="btn w-full select-none rounded p-3 px-6 text-3xl">
            購買
          </button>
        </btn-jigsaw-puzzle>
      </div>

      <div class="text-center text-xs text-red-500">
        {{ isActive ? `剩餘 ${timerCount} 秒` : ' ' }}
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useIntervalFn } from '@vueuse/core'
import { ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import BtnJigsawPuzzle from '../btn-jigsaw-puzzle.vue'

const { t } = useI18n()

const MAX_TIMER_COUNT = 4

const jigsawPuzzleRef = useTemplateRef('jigsawPuzzleRef')

const timerCount = ref(MAX_TIMER_COUNT)
const { isActive, pause, resume } = useIntervalFn(() => {
  timerCount.value--

  if (timerCount.value <= 0) {
    reset()
  }
}, 1000, {
  immediate: false,
})

const ticketOptions = ref([
  {
    id: 't1',
    area: '搖滾區',
    price: 5200,
    note: '在喇叭上,保證超搖滾',
  },
  {
    id: 't2',
    area: 'VIP 站區',
    price: 4200,
    note: '提供錨,不怕嗨到浮出水面',
  },
  {
    id: 't3',
    area: '奇怪視線區',
    price: 3200,
    note: '會看到很多不該看的東西',
  },
])
const selectedTicketId = ref('')

function reset() {
  selectedTicketId.value = ''
  jigsawPuzzleRef.value?.scatter()
  pause()
  timerCount.value = MAX_TIMER_COUNT
}

function handleDragStart() {
  if (isActive.value) {
    return
  }
  resume()
}
function handleSubmit() {
  // eslint-disable-next-line no-alert
  alert('此票已售完,請重新選擇')
  reset()
}
</script>

<style scoped lang="sass">
.btn
  background-color: light-dark(#444, #EEE)
  color: light-dark(#EEE, #444)
  transition-duration: 0.2s
  &:active
    transition-duration: 0.1s
    transform: scale(0.98)
</style>

原理

使用 SVG Mask 實作拼圖分割,並使用 Pointer 事件實作拖曳效果。

原始碼

API

Props

interface Props {
  /** 按鈕內文字 */
  label?: string;
  /** 是否停用 */
  disabled?: boolean;
  /** 同 CSS z-index */
  zIndex?: number | string;
  /** 同 html tabindex */
  tabindex?: number | string;

  /** 拼圖行數 */
  rowCount?: number;
  /** 拼圖列數 */
  colCount?: number;
}

Emits

const emit = defineEmits<{
  /** 開始拖動 */
  dragStart: [piece: Piece, evt: PointerEvent];
  dragging: [piece: Piece, evt: PointerEvent];
  dragStop: [piece: Piece, evt: PointerEvent];
  completed: [];
  click: [];
}>()

Methods

defineExpose({
  /** 打散拼圖 */
  scatter,
  /** 自動完成 */
  autoComplete,
})

Slots

defineSlots<{
  /** 按鈕 */
  default?: (params: { isAllCompleted: boolean }) => unknown;
}>()

v0.54.1