feat(fc2a): real modal store + usePanZoom composable

modal store loads image detail (with neighbors) on open, exposes goPrev/
goNext that re-fetch the next image, and implements optimistic tag
remove with rollback on API failure. createAndAdd is the bridge between
the autocomplete UI and a brand-new tag.

usePanZoom is a small composable: click-to-toggle 2x zoom centered on
click point, wheel-to-zoom up to 4x, drag-to-pan when zoomed. Pointer
events are used (touch + mouse + pen unified). Caller spreads handlers
onto the element it wants pannable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 12:33:14 -04:00
parent 899b193085
commit 8358d09348
2 changed files with 160 additions and 5 deletions
+81
View File
@@ -0,0 +1,81 @@
// Click-to-toggle zoom + drag-to-pan when zoomed + wheel-to-zoom.
// Returns reactive state + handlers a component can spread onto its
// root element.
import { reactive } from 'vue'
const MIN_SCALE = 1
const MAX_SCALE = 4
const WHEEL_SENSITIVITY = 0.0015
export function usePanZoom() {
const state = reactive({
scale: 1,
x: 0,
y: 0,
panning: false
})
let dragOriginX = 0
let dragOriginY = 0
let panOriginX = 0
let panOriginY = 0
function reset() {
state.scale = 1
state.x = 0
state.y = 0
}
function toggleZoom(ev) {
if (state.scale > 1) { reset(); return }
state.scale = 2
// Zoom centered on click point
if (ev && ev.currentTarget) {
const rect = ev.currentTarget.getBoundingClientRect()
const cx = ev.clientX - rect.left - rect.width / 2
const cy = ev.clientY - rect.top - rect.height / 2
state.x = -cx
state.y = -cy
}
}
function onWheel(ev) {
ev.preventDefault()
const delta = -ev.deltaY * WHEEL_SENSITIVITY
const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, state.scale + delta * state.scale))
state.scale = next
if (next === MIN_SCALE) { state.x = 0; state.y = 0 }
}
function onPointerDown(ev) {
if (state.scale === 1) return
state.panning = true
dragOriginX = ev.clientX
dragOriginY = ev.clientY
panOriginX = state.x
panOriginY = state.y
ev.currentTarget.setPointerCapture(ev.pointerId)
}
function onPointerMove(ev) {
if (!state.panning) return
state.x = panOriginX + (ev.clientX - dragOriginX)
state.y = panOriginY + (ev.clientY - dragOriginY)
}
function onPointerUp(ev) {
state.panning = false
try { ev.currentTarget.releasePointerCapture(ev.pointerId) } catch {}
}
return {
state,
handlers: {
onWheel,
onPointerDown,
onPointerMove,
onPointerUp,
onClick: toggleZoom
},
reset
}
}