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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,84 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
|
||||
// Placeholder modal store — real implementation lands in Task 19.
|
||||
export const useModalStore = defineStore('modal', () => {
|
||||
const api = useApi()
|
||||
|
||||
const currentImageId = ref(null)
|
||||
function open(id) { currentImageId.value = id }
|
||||
function close() { currentImageId.value = null }
|
||||
return { currentImageId, open, close }
|
||||
const current = ref(null) // full image detail from API
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function open(id) {
|
||||
currentImageId.value = id
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
current.value = await api.get(`/api/gallery/image/${id}`)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
current.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function close() {
|
||||
currentImageId.value = null
|
||||
current.value = null
|
||||
error.value = null
|
||||
}
|
||||
|
||||
async function goPrev() {
|
||||
if (current.value && current.value.neighbors.prev_id) {
|
||||
await open(current.value.neighbors.prev_id)
|
||||
}
|
||||
}
|
||||
async function goNext() {
|
||||
if (current.value && current.value.neighbors.next_id) {
|
||||
await open(current.value.neighbors.next_id)
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadTags() {
|
||||
if (!currentImageId.value) return
|
||||
const tags = await api.get(`/api/images/${currentImageId.value}/tags`)
|
||||
if (current.value) current.value.tags = tags
|
||||
}
|
||||
|
||||
async function removeTag(tagId) {
|
||||
if (!currentImageId.value) return
|
||||
// Optimistic UI: remove locally first, restore on error.
|
||||
const prev = current.value.tags
|
||||
current.value.tags = current.value.tags.filter(t => t.id !== tagId)
|
||||
try {
|
||||
await api.delete(`/api/images/${currentImageId.value}/tags/${tagId}`)
|
||||
} catch (e) {
|
||||
current.value.tags = prev
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function addExistingTag(tagId) {
|
||||
if (!currentImageId.value) return
|
||||
await api.post(`/api/images/${currentImageId.value}/tags`, {
|
||||
body: { tag_id: tagId, source: 'manual' }
|
||||
})
|
||||
await reloadTags()
|
||||
}
|
||||
|
||||
async function createAndAdd({ name, kind, fandom_id = null }) {
|
||||
const tag = await api.post('/api/tags', { body: { name, kind, fandom_id } })
|
||||
await addExistingTag(tag.id)
|
||||
}
|
||||
|
||||
const isOpen = computed(() => currentImageId.value !== null)
|
||||
const canPrev = computed(() => current.value?.neighbors?.prev_id != null)
|
||||
const canNext = computed(() => current.value?.neighbors?.next_id != null)
|
||||
|
||||
return {
|
||||
currentImageId, current, loading, error, isOpen, canPrev, canNext,
|
||||
open, close, goPrev, goNext, reloadTags, removeTag, addExistingTag, createAndAdd
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user