feat(explore): ← / → keyboard navigation through the walk
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s

Arrow keys walk the Explore breadcrumb trail: ← steps back, → goes forward to
an already-visited item or — with no forward history — jumps to a RANDOM
neighbour to keep the rabbit-hole going (operator-asked).

The trail gains a cursor (browser back/forward semantics): stepping back no
longer trims the forward branch, so → can return to it; a genuinely new walk
off a back-step truncates the stale branch then appends. The crumb-bar "current"
highlight follows the cursor, not the tip.

Arrows are ignored while typing a tag, but still navigate when the tag input is
focused-but-empty (it auto-focuses after every walk, so otherwise arrow-nav
would dead-end after one step). Modifier-key combos pass through untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
This commit is contained in:
2026-06-28 21:41:23 -04:00
parent ca1c17446c
commit 353b5d8087
2 changed files with 62 additions and 14 deletions
+38 -7
View File
@@ -19,6 +19,10 @@ export const useExploreStore = defineStore('explore', () => {
const anchor = ref(null) // /api/gallery/image/<id> payload
const neighbors = ref([]) // [{id, thumbnail_url, ...}]
const breadcrumb = ref([]) // [{id, thumbnail_url}] walked path
// Index of the current anchor within breadcrumb — browser-style back/forward.
// The trail keeps its forward branch when you step back (so ← / → can move
// through visited items); a NEW walk off a back-step truncates that branch.
const cursor = ref(-1)
const loading = ref(false)
const error = ref(null)
@@ -54,13 +58,39 @@ export const useExploreStore = defineStore('explore', () => {
}
}
// Forward walk appends; navigating to an id already in the trail (a
// breadcrumb click, or a loop back) TRIMS to it — so the route stays the
// single source of truth and the crumb bar never grows stale branches.
// The route is the source of truth; this reconciles the trail + cursor to it.
// Revisiting an id already on the path (← / →, a crumb click, or a loop) just
// MOVES the cursor there — the forward branch is preserved so → can return to
// it. A genuinely new image off a back-step truncates the stale forward branch
// (browser semantics), then appends and points the cursor at the new tip.
function _reconcileTrail (id, thumbnailUrl) {
const idx = breadcrumb.value.findIndex((c) => c.id === id)
if (idx >= 0) breadcrumb.value = breadcrumb.value.slice(0, idx + 1)
else breadcrumb.value = [...breadcrumb.value, { id, thumbnail_url: thumbnailUrl }]
if (idx >= 0) {
cursor.value = idx
return
}
const base = cursor.value < breadcrumb.value.length - 1
? breadcrumb.value.slice(0, cursor.value + 1)
: breadcrumb.value
breadcrumb.value = [...base, { id, thumbnail_url: thumbnailUrl }]
cursor.value = breadcrumb.value.length - 1
}
// ← target: the previous crumb (null at the start of the trail).
function backTarget () {
return cursor.value > 0 ? breadcrumb.value[cursor.value - 1].id : null
}
// → target: the next already-visited crumb if we'd stepped back, else a
// RANDOM neighbour to keep the rabbit-hole going. Null if neither exists.
function forwardTarget () {
if (cursor.value >= 0 && cursor.value < breadcrumb.value.length - 1) {
return breadcrumb.value[cursor.value + 1].id
}
if (neighbors.value.length) {
return neighbors.value[Math.floor(Math.random() * neighbors.value.length)].id
}
return null
}
function reset () {
@@ -68,6 +98,7 @@ export const useExploreStore = defineStore('explore', () => {
anchor.value = null
neighbors.value = []
breadcrumb.value = []
cursor.value = -1
error.value = null
loading.value = false
}
@@ -145,8 +176,8 @@ export const useExploreStore = defineStore('explore', () => {
function close () {}
return {
anchor, neighbors, breadcrumb, loading, error, NEIGHBOR_LIMIT,
anchorOn, reset,
anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT,
anchorOn, reset, backTarget, forwardTarget,
// host surface
current, currentImageId,
reloadTags, addExistingTag, removeTag, createAndAdd, close,