feat(explore): ← / → keyboard navigation through the walk
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:
@@ -19,6 +19,10 @@ export const useExploreStore = defineStore('explore', () => {
|
|||||||
const anchor = ref(null) // /api/gallery/image/<id> payload
|
const anchor = ref(null) // /api/gallery/image/<id> payload
|
||||||
const neighbors = ref([]) // [{id, thumbnail_url, ...}]
|
const neighbors = ref([]) // [{id, thumbnail_url, ...}]
|
||||||
const breadcrumb = ref([]) // [{id, thumbnail_url}] walked path
|
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 loading = ref(false)
|
||||||
const error = ref(null)
|
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
|
// The route is the source of truth; this reconciles the trail + cursor to it.
|
||||||
// breadcrumb click, or a loop back) TRIMS to it — so the route stays the
|
// Revisiting an id already on the path (← / →, a crumb click, or a loop) just
|
||||||
// single source of truth and the crumb bar never grows stale branches.
|
// 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) {
|
function _reconcileTrail (id, thumbnailUrl) {
|
||||||
const idx = breadcrumb.value.findIndex((c) => c.id === id)
|
const idx = breadcrumb.value.findIndex((c) => c.id === id)
|
||||||
if (idx >= 0) breadcrumb.value = breadcrumb.value.slice(0, idx + 1)
|
if (idx >= 0) {
|
||||||
else breadcrumb.value = [...breadcrumb.value, { id, thumbnail_url: thumbnailUrl }]
|
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 () {
|
function reset () {
|
||||||
@@ -68,6 +98,7 @@ export const useExploreStore = defineStore('explore', () => {
|
|||||||
anchor.value = null
|
anchor.value = null
|
||||||
neighbors.value = []
|
neighbors.value = []
|
||||||
breadcrumb.value = []
|
breadcrumb.value = []
|
||||||
|
cursor.value = -1
|
||||||
error.value = null
|
error.value = null
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -145,8 +176,8 @@ export const useExploreStore = defineStore('explore', () => {
|
|||||||
function close () {}
|
function close () {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
anchor, neighbors, breadcrumb, loading, error, NEIGHBOR_LIMIT,
|
anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT,
|
||||||
anchorOn, reset,
|
anchorOn, reset, backTarget, forwardTarget,
|
||||||
// host surface
|
// host surface
|
||||||
current, currentImageId,
|
current, currentImageId,
|
||||||
reloadTags, addExistingTag, removeTag, createAndAdd, close,
|
reloadTags, addExistingTag, removeTag, createAndAdd, close,
|
||||||
|
|||||||
@@ -22,9 +22,9 @@
|
|||||||
<button
|
<button
|
||||||
v-for="(c, i) in store.breadcrumb" :key="c.id"
|
v-for="(c, i) in store.breadcrumb" :key="c.id"
|
||||||
class="fc-ex__crumb"
|
class="fc-ex__crumb"
|
||||||
:class="{ 'fc-ex__crumb--current': i === store.breadcrumb.length - 1 }"
|
:class="{ 'fc-ex__crumb--current': i === store.cursor }"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-current="i === store.breadcrumb.length - 1 ? 'page' : undefined"
|
:aria-current="i === store.cursor ? 'page' : undefined"
|
||||||
:title="`Step ${i + 1}`"
|
:title="`Step ${i + 1}`"
|
||||||
@click="goTo(c.id)"
|
@click="goTo(c.id)"
|
||||||
>
|
>
|
||||||
@@ -191,13 +191,30 @@ function goTo (id) {
|
|||||||
|
|
||||||
function openInViewer (id) { modal.open(id) }
|
function openInViewer (id) { modal.open(id) }
|
||||||
|
|
||||||
// Modal-parity focus: T or "/" jumps to the tag input (the same shortcut the
|
// Keyboard: T or "/" jumps to the tag input (modal-parity); ←/→ walk the
|
||||||
// image modal binds), unless the caret is already in a text field.
|
// breadcrumb trail — ← steps back, → goes forward to an already-visited item
|
||||||
|
// or, with no forward history, jumps to a random neighbour (keep rabbit-holing).
|
||||||
function onKeyDown (ev) {
|
function onKeyDown (ev) {
|
||||||
if ((ev.key === 't' || ev.key === '/') && !isTextEntry(ev.target)) {
|
if (ev.metaKey || ev.ctrlKey || ev.altKey) return
|
||||||
const input = document.querySelector('.fc-tag-autocomplete input')
|
const inText = isTextEntry(ev.target)
|
||||||
if (input) { ev.preventDefault(); input.focus() }
|
if (ev.key === 't' || ev.key === '/') {
|
||||||
|
if (!inText) {
|
||||||
|
const input = document.querySelector('.fc-tag-autocomplete input')
|
||||||
|
if (input) { ev.preventDefault(); input.focus() }
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
if (ev.key !== 'ArrowLeft' && ev.key !== 'ArrowRight') return
|
||||||
|
// The tag input auto-focuses after every walk, so also allow navigation while
|
||||||
|
// it's focused-but-EMPTY (the caret has nowhere to go) — otherwise arrow-nav
|
||||||
|
// would dead-end after one step. Once a tag is being typed, arrows move the
|
||||||
|
// caret instead.
|
||||||
|
const t = ev.target
|
||||||
|
const inEmptyTagInput =
|
||||||
|
inText && t.value === '' && t.closest?.('.fc-tag-autocomplete')
|
||||||
|
if (inText && !inEmptyTagInput) return
|
||||||
|
const id = ev.key === 'ArrowLeft' ? store.backTarget() : store.forwardTarget()
|
||||||
|
if (id != null) { ev.preventDefault(); goTo(id) }
|
||||||
}
|
}
|
||||||
onMounted(() => document.addEventListener('keydown', onKeyDown))
|
onMounted(() => document.addEventListener('keydown', onKeyDown))
|
||||||
onUnmounted(() => document.removeEventListener('keydown', onKeyDown))
|
onUnmounted(() => document.removeEventListener('keydown', onKeyDown))
|
||||||
|
|||||||
Reference in New Issue
Block a user