Skip to content
Welcome to vote for your favorite component! You can also tell me anything you want to say! (*´∀`)~♥

DOM Emitter util

Can emit any web content! ੭ ˙∀˙ )੭

0

Example Usage

Basic Usage

The content put into the slot can be emitted as particles. If the DOM changes, the particle images will also automatically update.( •̀ ω •́ )✧

(Click on the card to switch styles)

Battery! Pull yourself together! Σ(ˊДˋ;)
2023/10/01
Purchased, kids love it (ゝ∀・)b
2022/12/06
Whatever you're smoking, keep it away from me (´ー`)
2024/04/03
Please don't let my boss see this monstrosity (。-`ω´-)
2025/07/11
View Source Code
vue
<template>
  <div class="w-full flex flex-col gap-4">
    <util-dom-emitter
      ref="emitterRef"
      :slot-list="keys(slots)"
      class="grid justify-items-center gap-2 md:grid-cols-2"
      slot-wrapper-class="  w-2/3 md:w-full"
      canvas-z-index="9999"
    >
      <template
        v-for="(slot, name) in slots"
        #[name]
        :key="name"
      >
        <div
          class="grid grid-cols-3 grid-rows-2 h-full cursor-pointer gap-2 rounded p-2 text-sm"
          :class="slot.type"
          @click="nextType(slot)"
        >
          <div class="row-span-2 flex-center">
            <img
              :src="slot.avatar"
              class="avatar row-span-2 aspect-square shrink-0 object-cover"
              @load="refreshDomImage()"
            >
          </div>

          <span
            class="col-span-2 row-span-1 whitespace-pre-line"
            v-text="t(slot.valueKey)"
          />

          <div class="col-span-2 row-span-1 flex">
            <span
              v-for="n in slot.rate"
              :key="n"
              class="rate-star"
            />

            <span
              class="flex-1 self-end text-right text-xs text-gray-300"
              v-text="slot.date"
            />
          </div>
        </div>
      </template>
    </util-dom-emitter>

    <div class="flex gap-2">
      <base-input
        v-model="emitInterval"
        type="range"
        :label="`${t('發射週期')} : ${emitInterval}`"
        :min="1"
        :max="1000"
        class="flex-1 border rounded-lg p-4"
      />

      <base-checkbox
        v-model="enableEmit"
        :label="t('開始發射')"
        class="border rounded-lg p-4"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { useIntervalFn } from '@vueuse/core'
import { debounce } from 'lodash-es'
import { nanoid } from 'nanoid'
import { keys } from 'remeda'
import { reactive, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseCheckbox from '../../base-checkbox.vue'
import BaseInput from '../../base-input.vue'
// import UtilDomEmitter from '../util-dom-emitter-2d.vue'
import UtilDomEmitter from '../util-dom-emitter.vue'

const { t } = useI18n()

const typeList = ['t1', 't2', 't3', 't4'] as const

interface SlotDatum {
  avatar: string;
  rate: number;
  valueKey: string;
  type: typeof typeList[number];
  date: string;
}

const defaultSlotDatum: SlotDatum = {
  avatar: '/logo.webp',
  rate: 0,
  valueKey: '',
  type: 't1',
  date: '',
}

const emitterRef = useTemplateRef('emitterRef')
const slots = reactive({
  [nanoid()]: {
    ...defaultSlotDatum,
    rate: 1,
    valueKey: 'msg1',
    date: '2023/10/01',
  },
  [nanoid()]: {
    ...defaultSlotDatum,
    avatar: '/low/profile.webp',
    rate: 5,
    valueKey: 'msg2',
    date: '2022/12/06',
  },
  [nanoid()]: {
    ...defaultSlotDatum,
    avatar: '/low//profile-2.webp',
    rate: 4,
    valueKey: 'msg3',
    date: '2024/04/03',
  },
  [nanoid()]: {
    ...defaultSlotDatum,
    avatar: '/low//profile-3.webp',
    rate: 3,
    valueKey: 'msg4',
    date: '2025/07/11',
  },
})

function nextType(data: SlotDatum) {
  const index = typeList.indexOf(data.type) + 1
  data.type = typeList.at(index % typeList.length) ?? 't1'
}

/** 偵測不到圖片載入變化,手動更新 DOM 圖片 */
const refreshDomImage = debounce(() => {
  emitterRef.value?.refreshDomImage()
}, 100)

const enableEmit = ref(false)
const emitInterval = ref(500)
useIntervalFn(() => {
  if (!enableEmit.value) {
    return
  }

  emitterRef.value?.emit({
    quantity: 4,
    x: [0, window.innerWidth],
    y: window.innerHeight + 80,
    vx: [-50, 50],
    vy: [-300, -800],
    a: 0,
    va: [-0.3, 0.3],
  })
}, emitInterval)
</script>

<style scoped lang="sass">
.t1
  color: #444
  background: #FDFDFD
  border: 1px solid #DDD
  .avatar
    background: #c2eeff
    border-radius: 50%
  .rate-star
    &::before
      content: '★'
      color: #f59e0b
.t2
  border-radius: 0px
  background-color: #0d9488
  border: 1px solid #0d9488
  color: white
  .avatar
    background: #99f6e4
    clip-path: polygon(30% 0%, 70% 0%, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0% 70%, 0% 30%)
  .rate-star
    &::before
      content: '🍕'
      color: #0d9488

.t3
  border-radius: 1rem
  background-color: #fef2f2
  border: 1px solid #fecaca
  color: #b91c1c
  .avatar
    border-radius: 1rem
  .rate-star
    &::before
      content: '❤️'
      color: #b91c1c
.t4
  border-radius: 999px
  padding-right: 2rem
  background-color: #fdf4ff
  border: 4px solid #f5d0fe
  color: #a855f7
  .avatar
    border-radius: 999px
    background: #f5d0fe
  .rate-star
    &::before
      content: '🔥'
      color: #a855f7
</style>

How it works

Retrieves the DOM in the slot and converts it into an image, and then uses the image as a particle for physics simulation.

With the particle data and image available, the final step is just drawing the particles in Canvas.(・∀・)9

Afterword

The first component developed using Shader makes its debut. Shooting a commemorative firework.✧⁑。٩(ˊᗜˋ*)و✧⁕。

Initially developed with Canvas 2D API, but as the number of particles increased, the performance became too tragic, so I decided to switch to Shader.

Easier said than done, Shaders really are like reading an alien language...(›´ω`‹ ), so I had to ask powerful AI to help me convert my original 2D version to Shader and explain it in detail.∠( ᐛ 」∠)_

Tried all models (Sonnet 4, GPT 4.1, GPT 4o, Gemini 2.5 Pro, GPT o3). The code generated by any single model couldn't run directly, and had various small errors that needed manual fixes.

In the end, only the code from GPT o3 could actually run. The code from other models, even after tweaking it to have zero errors, still wouldn't run properly. ( ˙꒳​˙)

I truly feel AI is an excellent teacher. Otherwise, like in the past, slowly searching and watching tutorials, I wouldn't have produced a single thing, and my enthusiasm would be exhausted first.( ˘・з・)

Now we can quickly validate ideas and verify interesting concepts, but it also certainly lowered some professional barriers to a certain extent.

I can only say we are entering the best of times, and the worst of times.(´・ω・`)

2D API vs Shader

I have kept the Canvas 2D API version. You can compare it with the code of the current Shader version.

It's easy to notice that the Shader version is N times more complex than the 2D version, but the performance is also enhanced N times over. Truly quite fascinating.੭ ˙ᗜ˙ )੭

Source Code

API

Props

interface Props {
  slotList: SlotList;
  gravity?: number;
  slotWrapperClass?: string;
  canvasZIndex?: number | string;
}

Methods

/** 粒子發射參數
 *
 * [number, number] 表示此範圍隨機值
 */
interface ParticleParams {
  quantity: number;
  x: number | [number, number];
  y: number | [number, number];
  vx: number | [number, number];
  vy: number | [number, number];
  /** 角度 */
  a: number | [number, number];
  /** 角速度 */
  va: number | [number, number];
}

/** 發射特定 slot,或者所有 slot 平均 */
type EmitParams = Record<keyof SlotList, ParticleParams> | ParticleParams

interface Expose {
  emit: (params: EmitParams) => void;
  /** 若粒子圖片與 DOM 有落差,可以手動更新 */
  refreshDomImage: () => void;
}

Slots

type Slots = {
  [K in SlotList[number]]: () => unknown;
}
If you have any suggestions or questions, feel free to email me( ´ ▽ ` )ノ

If you like cool components, feel free to " Buy me a cup of coffee " to show your support!
Cod Lin thanks you! (´▽`ʃ💖ƪ)

更新於:

v0.58.0