304 lines
9 KiB
Vue
304 lines
9 KiB
Vue
<template>
|
|
<div
|
|
class="card-stack"
|
|
:class="{ 'bucket-mode': isBucketMode && motion.rich.value }"
|
|
>
|
|
<!-- Depth shadow cards (visual stack effect) -->
|
|
<div class="card-shadow card-shadow-2" aria-hidden="true" />
|
|
<div class="card-shadow card-shadow-1" aria-hidden="true" />
|
|
|
|
<!-- Active card -->
|
|
<div
|
|
class="card-wrapper"
|
|
ref="cardEl"
|
|
:class="[dismissClass, { 'is-held': isHeld }]"
|
|
:style="cardStyle"
|
|
@pointerdown="onPointerDown"
|
|
@pointermove="onPointerMove"
|
|
@pointerup="onPointerUp"
|
|
@pointercancel="onPointerCancel"
|
|
>
|
|
<EmailCard
|
|
:item="item"
|
|
:expanded="isExpanded"
|
|
@expand="isExpanded = true"
|
|
@collapse="isExpanded = false"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed } from 'vue'
|
|
import { useMotion } from '../composables/useMotion'
|
|
import EmailCard from './EmailCard.vue'
|
|
import type { QueueItem } from '../stores/label'
|
|
|
|
const props = defineProps<{
|
|
item: QueueItem
|
|
isBucketMode: boolean
|
|
dismissType?: 'label' | 'skip' | 'discard' | null
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
label: [name: string]
|
|
skip: []
|
|
discard: []
|
|
'drag-start': []
|
|
'drag-end': []
|
|
'zone-hover': ['discard' | 'skip' | null]
|
|
'bucket-hover': [string | null]
|
|
}>()
|
|
|
|
const motion = useMotion()
|
|
const cardEl = ref<HTMLElement | null>(null)
|
|
const isExpanded = ref(false)
|
|
|
|
// Toss gesture state
|
|
const isHeld = ref(false)
|
|
const pickupX = ref(0)
|
|
const pickupY = ref(0)
|
|
const deltaX = ref(0)
|
|
const deltaY = ref(0)
|
|
const hoveredZone = ref<'discard' | 'skip' | null>(null)
|
|
const hoveredBucketName = ref<string | null>(null)
|
|
|
|
// Zone threshold: 7% of viewport width on each side
|
|
const ZONE_PCT = 0.07
|
|
|
|
// Fling detection — Option B: speed + direction alignment
|
|
// Plain array (not ref) — drives no template state, updates on every pointermove
|
|
const FLING_SPEED_PX_S = 600 // px/s minimum to qualify as a fling
|
|
const FLING_ALIGN = 0.707 // cos(45°) — velocity must point within 45° of horizontal
|
|
const FLING_WINDOW_MS = 50 // rolling sample window in ms
|
|
let velocityBuf: { x: number; y: number; t: number }[] = []
|
|
|
|
function onPointerDown(e: PointerEvent) {
|
|
if (!motion.rich.value) return
|
|
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
|
pickupX.value = e.clientX
|
|
pickupY.value = e.clientY
|
|
deltaX.value = 0
|
|
deltaY.value = 0
|
|
isHeld.value = true
|
|
hoveredZone.value = null
|
|
hoveredBucketName.value = null
|
|
velocityBuf = []
|
|
emit('drag-start')
|
|
}
|
|
|
|
function onPointerMove(e: PointerEvent) {
|
|
if (!isHeld.value) return
|
|
deltaX.value = e.clientX - pickupX.value
|
|
deltaY.value = e.clientY - pickupY.value
|
|
|
|
// Rolling velocity buffer — keep only the last FLING_WINDOW_MS of samples
|
|
const now = performance.now()
|
|
velocityBuf.push({ x: e.clientX, y: e.clientY, t: now })
|
|
while (velocityBuf.length > 1 && now - velocityBuf[0].t > FLING_WINDOW_MS) {
|
|
velocityBuf.shift()
|
|
}
|
|
|
|
const vw = window.innerWidth
|
|
const zone: 'discard' | 'skip' | null =
|
|
e.clientX < vw * ZONE_PCT ? 'discard' :
|
|
e.clientX > vw * (1 - ZONE_PCT) ? 'skip' :
|
|
null
|
|
if (zone !== hoveredZone.value) {
|
|
hoveredZone.value = zone
|
|
emit('zone-hover', zone)
|
|
}
|
|
|
|
// Bucket detection via hit-test (works through overlapping elements).
|
|
// Optional chain guards against JSDOM in tests (doesn't implement elementsFromPoint).
|
|
const els = document.elementsFromPoint?.(e.clientX, e.clientY) ?? []
|
|
const bucket = els.find(el => el.hasAttribute('data-label-key'))
|
|
const bucketName = bucket?.getAttribute('data-label-key') ?? null
|
|
if (bucketName !== hoveredBucketName.value) {
|
|
hoveredBucketName.value = bucketName
|
|
emit('bucket-hover', bucketName)
|
|
}
|
|
}
|
|
|
|
function onPointerUp(e: PointerEvent) {
|
|
if (!isHeld.value) return
|
|
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
|
isHeld.value = false
|
|
emit('drag-end')
|
|
emit('zone-hover', null)
|
|
emit('bucket-hover', null)
|
|
|
|
// Fling detection (Option B): fires before zone check so a fast fling
|
|
// resolves even if the pointer didn't reach the 7% edge zone
|
|
if (hoveredZone.value === null && hoveredBucketName.value === null
|
|
&& velocityBuf.length >= 2) {
|
|
const oldest = velocityBuf[0]
|
|
const newest = velocityBuf[velocityBuf.length - 1]
|
|
const dt = (newest.t - oldest.t) / 1000 // seconds
|
|
if (dt > 0) {
|
|
const vx = (newest.x - oldest.x) / dt
|
|
const vy = (newest.y - oldest.y) / dt
|
|
const speed = Math.sqrt(vx * vx + vy * vy)
|
|
// Require: fast enough AND velocity points within 45° of horizontal
|
|
if (speed >= FLING_SPEED_PX_S && Math.abs(vx) / speed >= FLING_ALIGN) {
|
|
velocityBuf = []
|
|
emit(vx < 0 ? 'discard' : 'skip')
|
|
return
|
|
}
|
|
}
|
|
}
|
|
velocityBuf = []
|
|
|
|
if (hoveredZone.value === 'discard') {
|
|
hoveredZone.value = null
|
|
hoveredBucketName.value = null
|
|
emit('discard')
|
|
} else if (hoveredZone.value === 'skip') {
|
|
hoveredZone.value = null
|
|
hoveredBucketName.value = null
|
|
emit('skip')
|
|
} else if (hoveredBucketName.value) {
|
|
const name = hoveredBucketName.value
|
|
hoveredZone.value = null
|
|
hoveredBucketName.value = null
|
|
emit('label', name)
|
|
} else {
|
|
// Snap back — reset deltas
|
|
deltaX.value = 0
|
|
deltaY.value = 0
|
|
hoveredZone.value = null
|
|
hoveredBucketName.value = null
|
|
}
|
|
}
|
|
|
|
function onPointerCancel(e: PointerEvent) {
|
|
if (!isHeld.value) return
|
|
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
|
isHeld.value = false
|
|
deltaX.value = 0
|
|
deltaY.value = 0
|
|
hoveredZone.value = null
|
|
hoveredBucketName.value = null
|
|
velocityBuf = []
|
|
emit('drag-end')
|
|
emit('zone-hover', null)
|
|
emit('bucket-hover', null)
|
|
}
|
|
|
|
const dismissClass = computed(() => {
|
|
if (!props.dismissType) return null
|
|
return `dismiss-${props.dismissType}`
|
|
})
|
|
|
|
const cardStyle = computed(() => {
|
|
if (!motion.rich.value || !isHeld.value) return {}
|
|
|
|
// Aura color: zone > bucket > neutral
|
|
const aura =
|
|
hoveredZone.value === 'discard' ? 'rgba(244,67,54,0.25)' :
|
|
hoveredZone.value === 'skip' ? 'rgba(255,152,0,0.25)' :
|
|
hoveredBucketName.value ? 'rgba(42,96,128,0.20)' :
|
|
'transparent'
|
|
|
|
return {
|
|
transform: `translate(${deltaX.value}px, ${deltaY.value}px) scale(0.35)`,
|
|
borderRadius: '50%',
|
|
background: aura,
|
|
transition: 'border-radius 150ms ease, background 150ms ease',
|
|
cursor: 'grabbing',
|
|
zIndex: 100,
|
|
userSelect: 'none',
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.card-stack {
|
|
position: relative;
|
|
min-height: 200px;
|
|
max-height: 2000px; /* effectively unlimited — needed for max-height transition */
|
|
overflow: hidden;
|
|
transition:
|
|
max-height 280ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
|
min-height 280ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
}
|
|
|
|
/* Bucket mode: collapse card stack to a small pill so buckets get more room */
|
|
.card-stack.bucket-mode {
|
|
min-height: 0;
|
|
max-height: 90px;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.card-stack.bucket-mode .card-shadow {
|
|
opacity: 0;
|
|
transition: opacity 180ms ease;
|
|
}
|
|
|
|
.card-stack.bucket-mode .card-wrapper {
|
|
clip-path: inset(35% 15% round 0.75rem);
|
|
opacity: 0.35;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.card-shadow {
|
|
position: absolute;
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
border-radius: var(--radius-card, 1rem);
|
|
background: var(--color-surface-raised, #fff);
|
|
border: 1px solid var(--color-border, #e0e4ed);
|
|
transition: opacity 180ms ease;
|
|
}
|
|
.card-shadow-1 { transform: translateY(8px) scale(0.97); opacity: 0.6; }
|
|
.card-shadow-2 { transform: translateY(16px) scale(0.94); opacity: 0.35; }
|
|
|
|
.card-wrapper {
|
|
position: relative;
|
|
z-index: 1;
|
|
border-radius: var(--radius-card, 1rem);
|
|
background: var(--color-surface-raised, #fff);
|
|
will-change: clip-path, opacity;
|
|
clip-path: inset(0% 0% round 1rem);
|
|
touch-action: none; /* prevent scroll from stealing the gesture */
|
|
cursor: grab;
|
|
transition:
|
|
clip-path 260ms cubic-bezier(0.4, 0, 0.2, 1),
|
|
opacity 220ms ease;
|
|
}
|
|
.card-wrapper.is-held {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
/* Dismissal animations — dismiss class is only applied during the motion.rich await window,
|
|
so no ancestor guard needed; :global(.rich-motion) was being miscompiled by Vue's scoped
|
|
CSS transformer (dropping the descendant selector entirely). */
|
|
.card-wrapper.dismiss-label {
|
|
animation: fileAway var(--card-dismiss, 350ms ease-in) forwards;
|
|
}
|
|
.card-wrapper.dismiss-discard {
|
|
animation: crumple var(--card-dismiss, 350ms ease-in) forwards;
|
|
}
|
|
.card-wrapper.dismiss-skip {
|
|
animation: slideUnder var(--card-skip, 300ms ease-out) forwards;
|
|
}
|
|
|
|
@keyframes fileAway {
|
|
to { transform: translateY(-120%) scale(0.85); opacity: 0; }
|
|
}
|
|
@keyframes crumple {
|
|
50% { transform: scale(0.95) rotate(2deg); filter: brightness(0.6) sepia(1) hue-rotate(-20deg); }
|
|
to { transform: scale(0) rotate(8deg); opacity: 0; }
|
|
}
|
|
@keyframes slideUnder {
|
|
to { transform: translateX(110%) rotate(5deg); opacity: 0; }
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.card-stack,
|
|
.card-wrapper {
|
|
transition: none;
|
|
}
|
|
}
|
|
</style>
|