diff --git a/astro.config.ts b/astro.config.ts index 324511a..04ccd0c 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -7,9 +7,9 @@ import { defineConfig } from 'astro/config' // https://astro.build/config export default defineConfig({ site: 'https://playground.shiftbrain.com/', - base: '/post/template', + base: '/post/hover-to-drop-text', server: { - open: '/post/template', + open: '/post/hover-to-drop-text', }, prefetch: true, integrations: [ diff --git a/package-lock.json b/package-lock.json index 14711dc..cdda103 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "playground-template", + "name": "playground-hover-to-drop-text", "lockfileVersion": 3, "requires": true, "packages": { @@ -13,6 +13,7 @@ "@tailwindcss/container-queries": "0.1.1", "@trivago/prettier-plugin-sort-imports": "4.3.0", "@types/alpinejs": "3.13.10", + "@types/lodash": "4.17.18", "@types/react": "18.3.3", "@types/react-dom": "18.3.0", "alpinejs": "3.14.1", @@ -24,7 +25,10 @@ "eslint-plugin-astro": "1.1.1", "eslint-plugin-jsx-a11y": "6.8.0", "eslint-plugin-tailwindcss": "3.15.1", + "gsap": "3.13.0", "leva": "0.9.35", + "lil-gui": "0.20.0", + "lodash": "4.17.21", "postcss-fluid-sizing-function": "0.0.2", "prettier": "3.2.5", "prettier-plugin-astro": "0.14.1", @@ -6153,6 +6157,11 @@ "rxjs": "^7.2.0" } }, + "node_modules/@types/lodash": { + "version": "4.17.18", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.18.tgz", + "integrity": "sha512-KJ65INaxqxmU6EoCiJmRPZC9H9RVWCRd349tXM2M3O5NA7cY6YL7c0bHAHQ93NOfTObEQ004kd2QVHs/r0+m4g==" + }, "node_modules/@types/mdast": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.3.tgz", @@ -11809,6 +11818,11 @@ "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" }, + "node_modules/gsap": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.13.0.tgz", + "integrity": "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==" + }, "node_modules/has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", @@ -13297,6 +13311,11 @@ "node": ">= 0.8.0" } }, + "node_modules/lil-gui": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/lil-gui/-/lil-gui-0.20.0.tgz", + "integrity": "sha512-k7Ipr0ztqslMA2XvM5z5ZaWhxQtnEOwJBfI/hmSuRh6q4iMG9L0boqqrnZSzBR1jzyJ28OMl47l65ILzRe1TdA==" + }, "node_modules/lilconfig": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", diff --git a/package.json b/package.json index c0f13a8..da66985 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@tailwindcss/container-queries": "0.1.1", "@trivago/prettier-plugin-sort-imports": "4.3.0", "@types/alpinejs": "3.13.10", + "@types/lodash": "4.17.18", "@types/react": "18.3.3", "@types/react-dom": "18.3.0", "alpinejs": "3.14.1", @@ -37,7 +38,10 @@ "eslint-plugin-astro": "1.1.1", "eslint-plugin-jsx-a11y": "6.8.0", "eslint-plugin-tailwindcss": "3.15.1", + "gsap": "3.13.0", "leva": "0.9.35", + "lil-gui": "0.20.0", + "lodash": "4.17.21", "postcss-fluid-sizing-function": "0.0.2", "prettier": "3.2.5", "prettier-plugin-astro": "0.14.1", diff --git a/public/thumbnail.webp b/public/thumbnail.webp index 22b93a9..e0969b6 100644 Binary files a/public/thumbnail.webp and b/public/thumbnail.webp differ diff --git a/src/assets/audio/button.wav b/src/assets/audio/button.wav new file mode 100644 index 0000000..759d70c Binary files /dev/null and b/src/assets/audio/button.wav differ diff --git a/src/assets/audio/notification.wav b/src/assets/audio/notification.wav new file mode 100644 index 0000000..fe3d79f Binary files /dev/null and b/src/assets/audio/notification.wav differ diff --git a/src/assets/audio/progress_loop.wav b/src/assets/audio/progress_loop.wav new file mode 100644 index 0000000..1179082 Binary files /dev/null and b/src/assets/audio/progress_loop.wav differ diff --git a/src/assets/audio/toggle_off.wav b/src/assets/audio/toggle_off.wav new file mode 100644 index 0000000..df524a5 Binary files /dev/null and b/src/assets/audio/toggle_off.wav differ diff --git a/src/meta.json b/src/meta.json new file mode 100644 index 0000000..b853b8f --- /dev/null +++ b/src/meta.json @@ -0,0 +1,11 @@ +{ + "slug": "hover-to-drop-text", + "title": "Hover-to-Drop Text", + "author": "komakine", + "tags": [ + "audio", + "animation", + "hover" + ], + "device": "pc" +} \ No newline at end of file diff --git a/src/pages/_alpine-example.astro b/src/pages/_alpine-example.astro deleted file mode 100644 index 8039ae1..0000000 --- a/src/pages/_alpine-example.astro +++ /dev/null @@ -1,22 +0,0 @@ ---- -/** - * こちらのファイルはテンプレート用です。 - * pages/index.astroにコピペして使用してください - */ -import Layout from '../layouts/Layout.astro' -import { title, slug } from '../meta.json' ---- - - -
- {/* contents start */} -
-

- -
- {/* contents end */} -
-
diff --git a/src/pages/_react-example.astro b/src/pages/_react-example.astro deleted file mode 100644 index c1e44a5..0000000 --- a/src/pages/_react-example.astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -/** - * こちらのファイルはテンプレート用です。 - * pages/index.astroにコピペして使用してください - */ -import Layout from '../layouts/Layout.astro' -import { title, slug } from '../meta.json' - -import Component from '../components/Component' ---- - - -
- {/* contents start */} - - {/* contents end */} -
-
diff --git a/src/pages/index.astro b/src/pages/index.astro index b16dc2d..7a386c8 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,17 +1,41 @@ --- -import Layout from '../layouts/Layout.astro' -import { title, slug } from '../meta.json' +import Layout from '@/layouts/Layout.astro' +import { title, slug } from '@/meta.json' ---
{/* contents start */} -
-
    -
  • Alpine example → _alpine-example.astro
  • -
  • React example → _react-example.astro
  • -
+
+
+
+
+
+
+
+

+ Sound effects: https://snd.dev/ +

{/* contents end */}
+ + diff --git a/src/scripts/alpinejs/drop-text.component.ts b/src/scripts/alpinejs/drop-text.component.ts new file mode 100644 index 0000000..5ad56b3 --- /dev/null +++ b/src/scripts/alpinejs/drop-text.component.ts @@ -0,0 +1,208 @@ +import sound1 from '@/assets/audio/button.wav' +import sound3 from '@/assets/audio/notification.wav' +import sound4 from '@/assets/audio/progress_loop.wav' +import sound5 from '@/assets/audio/toggle_off.wav' +import Alpine from 'alpinejs' +import gsap from 'gsap' +import { Physics2DPlugin } from 'gsap/Physics2DPlugin' +import { SplitText } from 'gsap/SplitText' +import GUI from 'lil-gui' +import debounce from 'lodash/debounce' + +Alpine.data('dropText', () => { + const soundSources = [sound1, sound3, sound4, sound5] as const + let sounds: HTMLAudioElement[] | null = null + + let splitInstance: SplitText | null = null + let splitInstance2: SplitText | null = null + + let pointerSpeed = 0 + let pointerLastX = 0 + let pointerLastY = 0 + let lastTime = 0 + + let isResetAnimating = false + + const gui = new GUI() + + return { + guiParams: { + text: '👉Hover me!!!', + textColor: '#1C1C1C', + gravity: 800, + acceleration: 1200, + velocity: { + min: 100, + max: 650, + }, + randomRotate: 90, + velocityMultiplier: 1, + spinMultiplier: 1, // 回転倍率 (1 = 現在と同等, 4 = 4倍激しく) + }, + init() { + gsap.ticker.fps(60) + gsap.registerPlugin(Physics2DPlugin, SplitText) + this.initAudio() + this.updateText() + this.setGui() + }, + initAudio() { + sounds = soundSources.map((src) => { + const audio = new Audio(src) + audio.preload = 'auto' + audio.volume = 0.5 + return audio + }) + }, + drop(el: HTMLElement, event: PointerEvent) { + // 既に落下開始済みなら二度反応させない + if (el.dataset.dropped || isResetAnimating) return + el.dataset.dropped = 'true' + el.classList.add('!pointer-events-none') + this.playSound() + + const rect = el.getBoundingClientRect() + const centerX = rect.left + rect.width / 2 + const centerY = rect.top + rect.height / 2 + const deltaX = event.clientX - centerX + const deltaY = event.clientY - centerY + + const angle = gsap.utils.wrap(0, 360, (Math.atan2(deltaY, deltaX) * 180) / Math.PI + 180) + const velocity = + gsap.utils.clamp( + this.guiParams.velocity.min, + this.guiParams.velocity.max, + pointerSpeed * 1.2, + ) * this.guiParams.velocityMultiplier + + gsap.to(el, { + duration: 5, + physics2D: { + angle: angle, + velocity: velocity, + gravity: this.guiParams.gravity, + // acceleration: this.guiParams.acceleration, + }, + rotation: + gsap.utils.random(-this.guiParams.randomRotate, this.guiParams.randomRotate) * + this.guiParams.spinMultiplier, + onComplete: () => { + el.style.visibility = 'hidden' + }, + }) + }, + onPointermove(event: PointerEvent) { + const now = performance.now() + if (lastTime) { + const dt = now - lastTime + const dx = event.clientX - pointerLastX + const dy = event.clientY - pointerLastY + const distance = Math.hypot(dx, dy) + pointerSpeed = (distance / dt) * 1000 // px/s + } + lastTime = now + pointerLastX = event.clientX + pointerLastY = event.clientY + }, + destroySplitInstance() { + if (splitInstance) { + splitInstance.revert() + splitInstance = null + } + if (splitInstance2) { + splitInstance2.revert() + splitInstance2 = null + } + }, + updateText() { + this.destroySplitInstance() + + // テキストを書き換え + this.$refs.hoverText.innerHTML = this.guiParams.text + this.$refs.staticText.innerHTML = this.guiParams.text + + // 再スプリット + splitInstance = SplitText.create(this.$refs.hoverText, { type: 'chars' }) + splitInstance2 = SplitText.create(this.$refs.staticText, { type: 'chars' }) + + splitInstance.chars.forEach((char) => { + char.classList.add('cursor-pointer') + // @ts-ignore + char.addEventListener('pointerenter', (event: PointerEvent) => this.drop(char, event)) + }) + }, + playSound() { + if (!sounds || !sounds.length) return + const audio = sounds[Math.floor(Math.random() * sounds.length)] + audio.currentTime = 0 + audio.volume = 0.5 + audio.play().catch(() => {}) + }, + randomText() { + const phrases = [ + 'Breathe in calm 😌', + 'Keep moving!', + 'Drift on silent clouds', + 'Slow moments, soft hearts', + 'Coffee steam and
morning hush ☕️', + 'Just hover it!', + 'One step, one smile 🙂', + 'Sunsets paint in whispers 🌅', + 'Peace blooms in pauses', + 'Still lake, still soul', + 'Sometimes the best journey is simply resting in the quiet places within yourself.', + 'Let the world spin while you sit still, feeling each breath like a gentle tide 🌿', + 'Warm tea, cool breeze', + 'Stars glow, worries fade ✨', + 'Calm minds, kind hearts', + ] + const index = Math.floor(Math.random() * phrases.length) + // 前回と同じテキストなら再度ランダム選択 + if (this.guiParams.text === phrases[index]) { + this.randomText() + return + } + this.guiParams.text = phrases[index] + this.updateText() + }, + reset() { + if (isResetAnimating || !splitInstance) return + splitInstance.chars.forEach((char) => { + if (char.dataset.dropped) { + isResetAnimating = true + gsap.to(char, { + duration: 0.8, + x: 0, + y: 0, + rotation: 0, + overwrite: true, + ease: 'power4.out', + onStart: () => { + char.style.visibility = '' + }, + onComplete: () => { + char.removeAttribute('data-dropped') + char.classList.remove('!pointer-events-none') + isResetAnimating = false + }, + }) + } + }) + }, + setGui() { + gui + .add(this.guiParams, 'text') + .name('Text') + .onChange(debounce(() => this.updateText(), 150)) + .listen() + gui.addColor(this.guiParams, 'textColor').name('Text Color') + gui.add(this.guiParams, 'gravity', 0, 3000).name('Gravity').step(1) + gui.add(this.guiParams.velocity, 'max', 110, 1000).name('MaxVelocity').step(1) + // gui.add(this.guiParams, 'randomRotate', 10, 1000).name('RandomRotate').step(1) + gui.add(this.guiParams, 'velocityMultiplier', 1, 2).name('VelocityMultiplier').step(0.1) + gui.add(this.guiParams, 'spinMultiplier', 1, 100).name('SpinMultiplier').step(1) + gui.add(this, 'randomText').name('RandomText') + gui.add(this, 'reset').name('Reset Text') + }, + } +}) diff --git a/src/scripts/alpinejs/example.component.ts b/src/scripts/alpinejs/example.component.ts deleted file mode 100644 index 144517d..0000000 --- a/src/scripts/alpinejs/example.component.ts +++ /dev/null @@ -1,29 +0,0 @@ -import Alpine from 'alpinejs' -import invariant from 'tiny-invariant' - -Alpine.data('example', (initialValue = 0) => { - // ensure that `initialValue` is of type number - invariant(typeof initialValue === 'number') - // then `initialValue` is inferred as a Number value - - /** - * For more complex type narrowing, consider using Superstruct: - * https://www.npmjs.com/package/superstruct - * - * @example - * import { assert, number, object, string } from 'superstruct'; - * const User = object({ id: number(), name: string() }); - * const input: unknown = { id: 1, name: 'Taro' }; - * assert(input, User); - */ - - return { - init() { - console.log('example component: init') - - const exampleStore = this.$store.example - Alpine.effect(() => console.log('`count` in example store:', exampleStore.count)) - // setTimeout(() => exampleStore.increment(), 3000) - }, - } -}) diff --git a/src/scripts/alpinejs/example.store.ts b/src/scripts/alpinejs/example.store.ts deleted file mode 100644 index 6a0f551..0000000 --- a/src/scripts/alpinejs/example.store.ts +++ /dev/null @@ -1,24 +0,0 @@ -import Alpine from 'alpinejs' - -const name = 'example' - -const store = { - count: 0, - - init() { - console.log('example store: init') - }, - - increment() { - console.log('example store: increment') - this.count++ - }, -} - -Alpine.store(name, store) - -declare module 'alpinejs' { - interface Stores { - [name]: typeof store - } -} diff --git a/tsconfig.json b/tsconfig.json index d85be1e..52ffe50 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,11 @@ "compilerOptions": { "types": ["vitest/importMeta"], "jsx": "react-jsx", - "jsxImportSource": "react" + "jsxImportSource": "react", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } }, "include": ["src"], "exclude": ["dist", "node_modules"]