feat: redesign floating add cat
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 6m14s

This commit is contained in:
2026-09-11 22:08:53 +08:00
parent 48f1186f4d
commit e07a1de1f5
3 changed files with 114 additions and 31 deletions
+57 -20
View File
@@ -8,14 +8,21 @@ const dragging = ref(false)
const snapping = ref(false)
const edge = ref<'left' | 'right'>('right')
const position = ref<{ x: number; y: number } | null>(null)
const pointer = ref<{ id: number; startX: number; startY: number; originX: number; originY: number } | null>(null)
const pupilOffset = ref({ x: 0, y: 0 })
const pointer = ref<{ id: number; startX: number; startY: number; originX: number; originY: number; lastX: number; lastY: number } | null>(null)
let suppressClick = false
let suppressClickTimer: number | undefined
let snappingTimer: number | undefined
const buttonStyle = computed(() => position.value
? { left: `${position.value.x}px`, top: `${position.value.y}px`, right: 'auto', bottom: 'auto' }
: {})
const buttonStyle = computed(() => ({
...(position.value ? { left: `${position.value.x}px`, top: `${position.value.y}px`, right: 'auto', bottom: 'auto' } : {}),
'--pupil-x': `${pupilOffset.value.x}px`,
'--pupil-y': `${pupilOffset.value.y}px`,
}))
function prefersReducedMotion() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
function clearSnappingTimer() {
if (snappingTimer) {
window.clearTimeout(snappingTimer)
@@ -24,11 +31,18 @@ function clearSnappingTimer() {
}
function startSnappingPulse() {
clearSnappingTimer()
snapping.value = true
if (prefersReducedMotion()) {
pupilOffset.value = { x: 0, y: 0 }
return
}
snappingTimer = window.setTimeout(() => {
snapping.value = false
snappingTimer = undefined
}, 220)
pupilOffset.value = { x: 0, y: 0 }
snapping.value = true
snappingTimer = window.setTimeout(() => {
snapping.value = false
snappingTimer = undefined
}, 460)
}, 200)
}
function updateEdge() {
if (!position.value) return
@@ -43,10 +57,11 @@ function startDrag(event: PointerEvent) {
if (!event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0)) return
clearSnappingTimer()
snapping.value = false
pupilOffset.value = { x: 0, y: 0 }
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
position.value = clampFabPosition(rect.left, rect.top, window.innerWidth, window.innerHeight, 56, 14, 84)
pointer.value = { id: event.pointerId, startX: event.clientX, startY: event.clientY, originX: rect.left, originY: rect.top }
pointer.value = { id: event.pointerId, startX: event.clientX, startY: event.clientY, originX: rect.left, originY: rect.top, lastX: event.clientX, lastY: event.clientY }
try { target.setPointerCapture?.(event.pointerId) } catch { /* pointer capture unavailable in synthetic events */ }
}
function moveDrag(event: PointerEvent) {
@@ -54,8 +69,14 @@ function moveDrag(event: PointerEvent) {
if (!start || start.id !== event.pointerId) return
const deltaX = event.clientX - start.startX
const deltaY = event.clientY - start.startY
const vectorX = event.clientX - start.lastX
const vectorY = event.clientY - start.lastY
start.lastX = event.clientX
start.lastY = event.clientY
if (!dragging.value && !isFabDrag(deltaX, deltaY)) return
dragging.value = true
const length = Math.hypot(vectorX, vectorY)
if (!prefersReducedMotion() && length > 1) pupilOffset.value = { x: vectorX / length * 1.5, y: vectorY / length * 1.1 }
position.value = clampFabPosition(start.originX + deltaX, start.originY + deltaY, window.innerWidth, window.innerHeight, 56, 14, 84)
}
function finishDrag(event: PointerEvent) {
@@ -69,7 +90,13 @@ function finishDrag(event: PointerEvent) {
updateEdge()
startSnappingPulse()
suppressClick = true
window.setTimeout(() => { suppressClick = false }, 180)
if (suppressClickTimer) window.clearTimeout(suppressClickTimer)
suppressClickTimer = window.setTimeout(() => {
suppressClick = false
suppressClickTimer = undefined
}, 180)
} else {
pupilOffset.value = { x: 0, y: 0 }
}
dragging.value = false
}
@@ -83,22 +110,32 @@ onMounted(() => window.addEventListener('resize', snapFabToCurrentViewport))
onUnmounted(() => {
window.removeEventListener('resize', snapFabToCurrentViewport)
clearSnappingTimer()
if (suppressClickTimer) window.clearTimeout(suppressClickTimer)
})
</script>
<template>
<button class="unified-fab" :class="{ dragging, snapping, awake: dragging, sleeping: !dragging, 'edge-left': edge === 'left', 'edge-right': edge === 'right' }" :style="buttonStyle" :aria-label="props.label" @pointerdown="startDrag" @pointermove="moveDrag" @pointerup="finishDrag" @pointercancel="finishDrag" @click="activate">
<button class="unified-fab" :class="{ dragging, snapping, 'edge-left': edge === 'left', 'edge-right': edge === 'right' }" :style="buttonStyle" :aria-label="props.label" @pointerdown="startDrag" @pointermove="moveDrag" @pointerup="finishDrag" @pointercancel="finishDrag" @click="activate">
<svg class="fab-cat" viewBox="0 0 56 56" aria-hidden="true">
<g class="fab-cat-body">
<path class="fab-cat-tail" d="M41 38c7 2 8 9 3 11-4 2-8-1-7-4 1-2 4-2 5 0"/>
<path class="fab-cat-back" d="M13 41c1-10 7-17 17-17 8 0 14 5 15 13 1 6-3 10-10 10H19c-5 0-7-2-6-6Z"/>
<path class="fab-cat-head" d="M12 24 15 12l8 6c4-2 9-2 13 0l8-6 2 13c0 9-7 15-17 15S12 33 12 24Z"/>
<path class="fab-cat-ear" d="m17 18-1-4 4 3m20 1 2-4-5 3"/>
<path class="fab-cat-eye fab-cat-eye--sleeping" d="M19 27c2 2 4 2 6 0m8 0c2 2 4 2 6 0"/>
<g class="fab-cat-eye fab-cat-eye--awake"><circle cx="22" cy="27" r="2"/><circle cx="36" cy="27" r="2"/></g>
<path class="fab-cat-face" d="m27 31 2 1 2-1m-2 1v2m-13-2-6-1m6 4-6 2m32-5 6-1m-6 4 6 2"/>
<g class="fab-cat__tail"><path d="M40.5 36.8 C46.2 38.4 47 43.2 43 46"/></g>
<g class="fab-cat__character">
<g class="fab-cat__ears">
<path class="fab-cat__ear fab-cat__ear--left" d="M15.8 19.2 14.2 10.1Q14 9 15.1 9.6L23 15.4Z"/>
<path class="fab-cat__inner-ear" d="M17 16.1 16 12.5l4.2 3.1Z"/>
<path class="fab-cat__ear fab-cat__ear--right" d="M33.7 15.2 42.1 9.4Q43.2 8.7 43 10.1l-1.6 9.2Z"/>
<path class="fab-cat__inner-ear" d="m37 15.5 4-3.1-.7 4.1Z"/>
</g>
<g class="fab-cat__head">
<path class="fab-cat__face-shape" d="M13 26.2C13 18.2 19.1 14.2 28.1 14.2c9.1 0 15.3 4.1 15.3 12.1 0 9.5-6.2 15-15.2 15S13 35.8 13 26.2Z"/>
<g class="fab-cat__eyes">
<g class="fab-cat__eye fab-cat__eye--left"><ellipse cx="21.7" cy="28.1" rx="3.15" ry="2.25"/></g>
<g class="fab-cat__eye fab-cat__eye--right"><ellipse cx="34.5" cy="28.1" rx="3.15" ry="2.25"/></g>
</g>
<g class="fab-cat__pupils"><circle cx="21.7" cy="28.1" r="1.25"/><circle cx="34.5" cy="28.1" r="1.25"/></g>
<g class="fab-cat__muzzle"><path class="fab-cat__nose" d="M26.7 32.5Q28.1 33.6 29.5 32.5 28.1 31.5 26.7 32.5Z"/><path class="fab-cat__mouth" d="M28.1 33.3v.9m0 0q-1.3 1-2.4.5m2.4-.5q1.3 1 2.4.5"/></g>
<path class="fab-cat__plus" d="M24.8 20.4 H31.4 M28.1 17.1 V23.7"/>
</g>
</g>
<g class="fab-cat-sleep"><text x="39" y="13">z</text><text x="45" y="8">z</text></g>
</svg>
</button>
</template>