fix(modal): next arrow clears metadata panel + arrows work in empty tag input
Image viewer (#609): - The next (▶) arrow was offset from the viewport edge (right:16px) so it floated over the 320px metadata side panel. Offset it off a shared --fc-side-w var so it sits at the image's right edge instead; full-width again below 900px when the panel stacks under the image. - Arrow nav was fully disabled whenever a text field was focused. Now it yields to the caret ONLY when the field has text; an empty tag-entry field still navigates ←/→. Extracted to utils/textEntry.js (arrowNavAllowed). ESC behaviour unchanged (already closes the modal, overlay-aware). Test: arrowNavAllowed — empty/non-text → navigate, text present → don't. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useModalStore } from '../../stores/modal.js'
|
import { useModalStore } from '../../stores/modal.js'
|
||||||
|
import { arrowNavAllowed } from '../../utils/textEntry.js'
|
||||||
import ImageCanvas from './ImageCanvas.vue'
|
import ImageCanvas from './ImageCanvas.vue'
|
||||||
import VideoCanvas from './VideoCanvas.vue'
|
import VideoCanvas from './VideoCanvas.vue'
|
||||||
import TagPanel from './TagPanel.vue'
|
import TagPanel from './TagPanel.vue'
|
||||||
@@ -105,11 +106,13 @@ function onKeyDown(ev) {
|
|||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
emit('close')
|
emit('close')
|
||||||
} else if (ev.key === 'ArrowLeft') {
|
} else if (ev.key === 'ArrowLeft') {
|
||||||
if (isTextEntry(ev.target)) return
|
// Navigate unless the caret is in a non-empty text field (then let it move
|
||||||
|
// through the text). An empty tag-entry field still navigates.
|
||||||
|
if (!arrowNavAllowed(ev.target)) return
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
modal.goPrev()
|
modal.goPrev()
|
||||||
} else if (ev.key === 'ArrowRight') {
|
} else if (ev.key === 'ArrowRight') {
|
||||||
if (isTextEntry(ev.target)) return
|
if (!arrowNavAllowed(ev.target)) return
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
modal.goNext()
|
modal.goNext()
|
||||||
}
|
}
|
||||||
@@ -135,17 +138,14 @@ watch(() => modal.currentImageId, async () => {
|
|||||||
function nextFrame() {
|
function nextFrame() {
|
||||||
return new Promise(resolve => requestAnimationFrame(resolve))
|
return new Promise(resolve => requestAnimationFrame(resolve))
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTextEntry(el) {
|
|
||||||
if (!el) return false
|
|
||||||
const tag = el.tagName
|
|
||||||
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-viewer {
|
.fc-viewer {
|
||||||
position: fixed; inset: 0; z-index: 2000;
|
position: fixed; inset: 0; z-index: 2000;
|
||||||
|
/* Single source of truth for the metadata side-panel width — the next
|
||||||
|
arrow offsets off it so it never overlaps the panel. */
|
||||||
|
--fc-side-w: 320px;
|
||||||
/* Obsidian haze (#14171A = 20,23,26) — same palette as TopNav,
|
/* Obsidian haze (#14171A = 20,23,26) — same palette as TopNav,
|
||||||
mid-opacity + blur so the page behind shows through faintly. */
|
mid-opacity + blur so the page behind shows through faintly. */
|
||||||
background: rgba(20, 23, 26, 0.65);
|
background: rgba(20, 23, 26, 0.65);
|
||||||
@@ -174,7 +174,12 @@ function isTextEntry(el) {
|
|||||||
position: absolute; top: 72px; right: 16px; z-index: 3;
|
position: absolute; top: 72px; right: 16px; z-index: 3;
|
||||||
}
|
}
|
||||||
.fc-viewer__nav--prev { left: 16px; transform: translateY(-50%); }
|
.fc-viewer__nav--prev { left: 16px; transform: translateY(-50%); }
|
||||||
.fc-viewer__nav--next { right: 16px; transform: translateY(-50%); }
|
/* Sit just inside the image area, clear of the metadata side panel —
|
||||||
|
not floating over it (operator-flagged 2026-06-04). */
|
||||||
|
.fc-viewer__nav--next {
|
||||||
|
right: calc(var(--fc-side-w) + 16px);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
.fc-viewer__body {
|
.fc-viewer__body {
|
||||||
flex: 1; display: flex; min-height: 0;
|
flex: 1; display: flex; min-height: 0;
|
||||||
}
|
}
|
||||||
@@ -187,7 +192,7 @@ function isTextEntry(el) {
|
|||||||
min-width: 0; min-height: 0;
|
min-width: 0; min-height: 0;
|
||||||
}
|
}
|
||||||
.fc-viewer__side {
|
.fc-viewer__side {
|
||||||
width: 320px; flex-shrink: 0;
|
width: var(--fc-side-w); flex-shrink: 0;
|
||||||
background: rgb(var(--v-theme-surface));
|
background: rgb(var(--v-theme-surface));
|
||||||
border-left: 1px solid rgb(var(--v-theme-surface-light));
|
border-left: 1px solid rgb(var(--v-theme-surface-light));
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -195,6 +200,8 @@ function isTextEntry(el) {
|
|||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.fc-viewer__body { flex-direction: column; }
|
.fc-viewer__body { flex-direction: column; }
|
||||||
|
/* Side panel drops below the image — the next arrow uses the full width. */
|
||||||
|
.fc-viewer__nav--next { right: 16px; }
|
||||||
.fc-viewer__side {
|
.fc-viewer__side {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 40vh;
|
max-height: 40vh;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Helpers for deciding whether a keyboard shortcut should fire or yield to a
|
||||||
|
// focused text field.
|
||||||
|
|
||||||
|
export function isTextEntry (el) {
|
||||||
|
if (!el) return false
|
||||||
|
const tag = el.tagName
|
||||||
|
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasText (el) {
|
||||||
|
if (!el) return false
|
||||||
|
if (el.isContentEditable) return (el.textContent || '').length > 0
|
||||||
|
return (el.value || '').length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prev/next arrow navigation should fire UNLESS focus is in a text entry that
|
||||||
|
// already has content — in that case the arrows belong to the caret so it can
|
||||||
|
// move through the text. An empty (or non-text) target still navigates.
|
||||||
|
export function arrowNavAllowed (target) {
|
||||||
|
return !(isTextEntry(target) && hasText(target))
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
import { arrowNavAllowed, hasText, isTextEntry } from '../src/utils/textEntry.js'
|
||||||
|
|
||||||
|
describe('textEntry helpers', () => {
|
||||||
|
it('navigates when the target is not a text field', () => {
|
||||||
|
expect(arrowNavAllowed(document.createElement('div'))).toBe(true)
|
||||||
|
expect(arrowNavAllowed(null)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigates when a focused tag input is empty', () => {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.value = ''
|
||||||
|
expect(arrowNavAllowed(input)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT navigate when the input has text (caret moves instead)', () => {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.value = 'naruto'
|
||||||
|
expect(arrowNavAllowed(input)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isTextEntry / hasText recognise inputs and textareas', () => {
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = 'x'
|
||||||
|
expect(isTextEntry(ta)).toBe(true)
|
||||||
|
expect(hasText(ta)).toBe(true)
|
||||||
|
expect(isTextEntry(document.createElement('div'))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user