PageUp/PgDn/Home/End navigation; fix rename API path; immediate local rename update
This commit is contained in:
@@ -95,6 +95,8 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
event.key === 'ArrowDown' ||
|
event.key === 'ArrowDown' ||
|
||||||
event.key === 'ArrowLeft' ||
|
event.key === 'ArrowLeft' ||
|
||||||
event.key === 'ArrowRight' ||
|
event.key === 'ArrowRight' ||
|
||||||
|
event.key === 'PageUp' ||
|
||||||
|
event.key === 'PageDown' ||
|
||||||
(c && event.code === 'Space')
|
(c && event.code === 'Space')
|
||||||
) {
|
) {
|
||||||
if (!input) event.preventDefault()
|
if (!input) event.preventDefault()
|
||||||
@@ -104,6 +106,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
//console.log("key pressed", event)
|
//console.log("key pressed", event)
|
||||||
/// Long if-else machina for all keys we handle here
|
/// Long if-else machina for all keys we handle here
|
||||||
let arrow = ''
|
let arrow = ''
|
||||||
|
let paging = ''
|
||||||
const inHeader = !!(event.target as HTMLElement).closest('.headermain')
|
const inHeader = !!(event.target as HTMLElement).closest('.headermain')
|
||||||
const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb')
|
const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb')
|
||||||
// Handle arrows: in search input with text, only up/down; otherwise all arrows
|
// Handle arrows: in search input with text, only up/down; otherwise all arrows
|
||||||
@@ -118,9 +121,19 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
// Don't intercept arrows for non-search inputs (e.g. rename input)
|
// Don't intercept arrows for non-search inputs (e.g. rename input)
|
||||||
if (input && !searchInput) return
|
if (input && !searchInput) return
|
||||||
arrow = dir
|
arrow = dir
|
||||||
|
} else if (
|
||||||
|
event.key === 'PageUp' ||
|
||||||
|
event.key === 'PageDown' ||
|
||||||
|
event.key === 'Home' ||
|
||||||
|
event.key === 'End'
|
||||||
|
) {
|
||||||
|
if (input) return
|
||||||
|
paging = event.key
|
||||||
}
|
}
|
||||||
if (arrow) {
|
if (arrow) {
|
||||||
// Arrow key handling - fall through to bottom
|
// Arrow key handling - fall through to bottom
|
||||||
|
} else if (paging) {
|
||||||
|
// Paging/navigation key handling - fall through to bottom
|
||||||
}
|
}
|
||||||
// Find: process on keydown so that we can bypass the built-in search hotkey
|
// Find: process on keydown so that we can bypass the built-in search hotkey
|
||||||
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
||||||
@@ -239,12 +252,28 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (paging && !keyup && !inHeader && !inBreadcrumb) {
|
||||||
|
switch (paging) {
|
||||||
|
case 'PageUp':
|
||||||
|
f = () => fileExplorer.pageUp?.(event)
|
||||||
|
break
|
||||||
|
case 'PageDown':
|
||||||
|
f = () => fileExplorer.pageDown?.(event)
|
||||||
|
break
|
||||||
|
case 'Home':
|
||||||
|
f = () => fileExplorer.home?.(event)
|
||||||
|
break
|
||||||
|
case 'End':
|
||||||
|
f = () => fileExplorer.end?.(event)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (f) {
|
if (f) {
|
||||||
// Initial move, then t0 delay until repeats at tr intervals
|
// Initial move, then t0 delay until repeats at tr intervals
|
||||||
const t0 = 200,
|
const t0 = 200,
|
||||||
tr = event.altKey ? 20 : 100
|
tr = event.altKey ? 20 : 100
|
||||||
f()
|
f()
|
||||||
|
if (paging === 'Home' || paging === 'End') return
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
timer = setInterval(f, tr)
|
timer = setInterval(f, tr)
|
||||||
}, t0 - tr)
|
}, t0 - tr)
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import { apiFetch } from '@/repositories/Client'
|
|||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { formatSize } from '@/utils'
|
import { formatSize } from '@/utils'
|
||||||
|
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
@@ -112,21 +113,95 @@ const parseErrorMessage = async (res: Response) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getCursorIndex = () =>
|
||||||
|
store.cursor
|
||||||
|
? props.documents.findIndex(doc => doc.key === store.cursor)
|
||||||
|
: props.documents.length
|
||||||
|
|
||||||
|
const getDocElement = (key: string) =>
|
||||||
|
document.getElementById(`file-${key}`) as HTMLElement | null
|
||||||
|
|
||||||
|
const moveCursorTo = (moveto: number, ev: KeyboardEvent | null) => {
|
||||||
|
const select = !!ev?.shiftKey
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length === 0) {
|
||||||
|
store.cursor = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const N = docs.length
|
||||||
|
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||||
|
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||||
|
const index = getCursorIndex()
|
||||||
|
|
||||||
|
store.cursor = docs[moveto]?.key ?? ''
|
||||||
|
const tr = store.cursor ? getDocElement(store.cursor) : null
|
||||||
|
if (select) {
|
||||||
|
let [begin, end] = moveto >= index ? [index, moveto] : [moveto, index]
|
||||||
|
for (let p = begin; p !== end; p = increment(p, 1)) {
|
||||||
|
if (p === N) continue
|
||||||
|
const key = docs[p]!.key
|
||||||
|
if (store.selected.has(key)) store.selected.delete(key)
|
||||||
|
else store.selected.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keepCursorVisibleSmooth(tr)
|
||||||
|
if (moveto === N) {
|
||||||
|
if (index > moveto) focusBreadcrumb()
|
||||||
|
else focusHeader()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length === 0) return
|
||||||
|
const scroller =
|
||||||
|
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
|
||||||
|
const currentIndex = getCursorIndex()
|
||||||
|
const currentEl = store.cursor ? getDocElement(store.cursor) : null
|
||||||
|
const currentCenter = currentEl
|
||||||
|
? currentEl.getBoundingClientRect().top +
|
||||||
|
currentEl.getBoundingClientRect().height / 2
|
||||||
|
: scroller.getBoundingClientRect().top + scroller.clientHeight / 2
|
||||||
|
const targetCenter =
|
||||||
|
currentCenter + direction * Math.max(120, scroller.clientHeight - 140)
|
||||||
|
|
||||||
|
let bestIndex = direction > 0 ? docs.length - 1 : 0
|
||||||
|
let bestDistance = Number.POSITIVE_INFINITY
|
||||||
|
for (let i = 0; i < docs.length; i++) {
|
||||||
|
if (
|
||||||
|
currentIndex !== docs.length &&
|
||||||
|
((direction > 0 && i <= currentIndex) || (direction < 0 && i >= currentIndex))
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
const el = getDocElement(docs[i]!.key)
|
||||||
|
if (!el) continue
|
||||||
|
const center =
|
||||||
|
el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2
|
||||||
|
const distance = Math.abs(center - targetCenter)
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance
|
||||||
|
bestIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(bestIndex, ev)
|
||||||
|
}
|
||||||
|
|
||||||
// File rename
|
// File rename
|
||||||
const editing = shallowRef<Doc | null>(null)
|
const editing = shallowRef<Doc | null>(null)
|
||||||
const rename = async (doc: Doc, newName: string) => {
|
const rename = async (doc: Doc, newName: string) => {
|
||||||
const oldName = doc.name
|
const oldName = doc.name
|
||||||
doc.name = newName // We should get an update from watch but this is quicker
|
doc.name = newName // We should get an update from watch but this is quicker
|
||||||
|
store.documentsChanged()
|
||||||
try {
|
try {
|
||||||
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
||||||
const res = await apiFetch(
|
const targetUrl = `${dstUrl}${dstUrl.endsWith('/') ? '' : '/'}${encodeURIComponent(newName)}`
|
||||||
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`,
|
const res = await apiFetch(`${targetUrl}?mv=${doc.key}`, { method: 'POST' })
|
||||||
{ method: 'POST' }
|
|
||||||
)
|
|
||||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Rename failed', err)
|
console.error('Rename failed', err)
|
||||||
doc.name = oldName
|
doc.name = oldName
|
||||||
|
store.documentsChanged()
|
||||||
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,14 +251,33 @@ defineExpose({
|
|||||||
} else {
|
} else {
|
||||||
store.selected.add(key)
|
store.selected.add(key)
|
||||||
}
|
}
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(1, null)
|
this.cursorMove(1, null)
|
||||||
},
|
},
|
||||||
up(ev: KeyboardEvent) {
|
up(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(-1, ev)
|
this.cursorMove(-1, ev)
|
||||||
},
|
},
|
||||||
down(ev: KeyboardEvent) {
|
down(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(1, ev)
|
this.cursorMove(1, ev)
|
||||||
},
|
},
|
||||||
|
pageUp(ev: KeyboardEvent) {
|
||||||
|
pageMove(-1, ev)
|
||||||
|
},
|
||||||
|
pageDown(ev: KeyboardEvent) {
|
||||||
|
pageMove(1, ev)
|
||||||
|
},
|
||||||
|
home(ev: KeyboardEvent) {
|
||||||
|
if (!props.documents.length) return
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(0, ev)
|
||||||
|
},
|
||||||
|
end(ev: KeyboardEvent) {
|
||||||
|
if (!props.documents.length) return
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(props.documents.length - 1, ev)
|
||||||
|
},
|
||||||
left(ev: KeyboardEvent) {
|
left(ev: KeyboardEvent) {
|
||||||
// Only go back if we're in a subfolder (not at root)
|
// Only go back if we're in a subfolder (not at root)
|
||||||
if (props.path.length > 0) {
|
if (props.path.length > 0) {
|
||||||
@@ -197,8 +291,6 @@ defineExpose({
|
|||||||
if (a) a.click()
|
if (a) a.click()
|
||||||
},
|
},
|
||||||
cursorMove(d: number, ev: KeyboardEvent | null) {
|
cursorMove(d: number, ev: KeyboardEvent | null) {
|
||||||
const select = !!ev?.shiftKey
|
|
||||||
// Move cursor up or down (keyboard navigation)
|
|
||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
if (docs.length === 0) {
|
if (docs.length === 0) {
|
||||||
store.cursor = ''
|
store.cursor = ''
|
||||||
@@ -207,35 +299,9 @@ defineExpose({
|
|||||||
const N = docs.length
|
const N = docs.length
|
||||||
const mod = (a: number, b: number) => ((a % b) + b) % b
|
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||||
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||||
const index = store.cursor
|
const index = getCursorIndex()
|
||||||
? docs.findIndex(doc => doc.key === store.cursor)
|
|
||||||
: docs.length
|
|
||||||
const moveto = increment(index, d)
|
const moveto = increment(index, d)
|
||||||
store.cursor = docs[moveto]?.key ?? ''
|
moveCursorTo(moveto, ev)
|
||||||
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
|
|
||||||
if (select) {
|
|
||||||
// Go forwards, possibly wrapping over the end; the last entry is not toggled
|
|
||||||
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
|
||||||
for (let p = begin; p !== end; p = increment(p, 1)) {
|
|
||||||
if (p === N) continue
|
|
||||||
const key = docs[p]!.key
|
|
||||||
if (store.selected.has(key)) store.selected.delete(key)
|
|
||||||
else store.selected.add(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
|
||||||
scrolltr = tr
|
|
||||||
if (!scrolltimer) {
|
|
||||||
scrolltimer = setTimeout(() => {
|
|
||||||
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
||||||
scrolltimer = null
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
|
||||||
if (moveto === N) {
|
|
||||||
if (d < 0) focusBreadcrumb()
|
|
||||||
else focusHeader()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const focusHeader = () => {
|
const focusHeader = () => {
|
||||||
@@ -248,8 +314,9 @@ const focusBreadcrumb = () => {
|
|||||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||||
if (el) el.focus()
|
if (el) el.focus()
|
||||||
}
|
}
|
||||||
let scrolltimer: any = null
|
const keyboardFollowScroll = createKeyboardFollowScroll()
|
||||||
let scrolltr: any = null
|
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
||||||
|
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
||||||
if (editing.value) store.cursor = editing.value?.key
|
if (editing.value) store.cursor = editing.value?.key
|
||||||
@@ -257,7 +324,7 @@ watchEffect(() => {
|
|||||||
const a = document.querySelector(
|
const a = document.querySelector(
|
||||||
`#file-${store.cursor} .name a`
|
`#file-${store.cursor} .name a`
|
||||||
) as HTMLAnchorElement | null
|
) as HTMLAnchorElement | null
|
||||||
if (a) a.focus()
|
if (a) a.focus({ preventScroll: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
@@ -276,11 +343,11 @@ onMounted(() => {
|
|||||||
modifiedTimer = setInterval(updateModified, 1000)
|
modifiedTimer = setInterval(updateModified, 1000)
|
||||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||||
if (active) {
|
if (active) {
|
||||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
active.focus({ preventScroll: true })
|
||||||
active.focus()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
keyboardFollowScroll.cancel()
|
||||||
clearInterval(modifiedTimer)
|
clearInterval(modifiedTimer)
|
||||||
})
|
})
|
||||||
const mkdir = async (doc: Doc, name: string) => {
|
const mkdir = async (doc: Doc, name: string) => {
|
||||||
|
|||||||
+102
-133
@@ -20,6 +20,7 @@ import { apiFetch } from '@/repositories/Client'
|
|||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import type { SortOrder } from '@/utils/docsort'
|
import type { SortOrder } from '@/utils/docsort'
|
||||||
|
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
@@ -64,16 +65,16 @@ const exit = () => {
|
|||||||
const rename = async (doc: Doc, newName: string) => {
|
const rename = async (doc: Doc, newName: string) => {
|
||||||
const oldName = doc.name
|
const oldName = doc.name
|
||||||
doc.name = newName // We should get an update from watch but this is quicker
|
doc.name = newName // We should get an update from watch but this is quicker
|
||||||
|
store.documentsChanged()
|
||||||
try {
|
try {
|
||||||
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
||||||
const res = await apiFetch(
|
const targetUrl = `${dstUrl}${dstUrl.endsWith('/') ? '' : '/'}${encodeURIComponent(newName)}`
|
||||||
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`,
|
const res = await apiFetch(`${targetUrl}?mv=${doc.key}`, { method: 'POST' })
|
||||||
{ method: 'POST' }
|
|
||||||
)
|
|
||||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Rename failed', err)
|
console.error('Rename failed', err)
|
||||||
doc.name = oldName
|
doc.name = oldName
|
||||||
|
store.documentsChanged()
|
||||||
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,6 +184,81 @@ const updateColumns = () => {
|
|||||||
if (Number.isFinite(parsedEm) && parsedEm > 0) emPx.value = parsedEm
|
if (Number.isFinite(parsedEm) && parsedEm > 0) emPx.value = parsedEm
|
||||||
}
|
}
|
||||||
const columns = computed(() => columnCount.value)
|
const columns = computed(() => columnCount.value)
|
||||||
|
|
||||||
|
const getCursorIndex = () =>
|
||||||
|
store.cursor
|
||||||
|
? props.documents.findIndex(doc => doc.key === store.cursor)
|
||||||
|
: props.documents.length
|
||||||
|
|
||||||
|
const getDocElement = (key: string) =>
|
||||||
|
document.getElementById(`file-${key}`) as HTMLElement | null
|
||||||
|
|
||||||
|
const moveCursorTo = (moveto: number, ev: KeyboardEvent | null) => {
|
||||||
|
const select = !!ev?.shiftKey
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length === 0) {
|
||||||
|
store.cursor = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const N = docs.length
|
||||||
|
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||||
|
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||||
|
const index = getCursorIndex()
|
||||||
|
|
||||||
|
store.cursor = docs[moveto]?.key ?? ''
|
||||||
|
const tr = store.cursor ? getDocElement(store.cursor) : null
|
||||||
|
if (select) {
|
||||||
|
let [begin, end] = moveto >= index ? [index, moveto] : [moveto, index]
|
||||||
|
for (let p = begin; p !== end; p = increment(p, 1)) {
|
||||||
|
if (p === N) continue
|
||||||
|
const key = docs[p]!.key
|
||||||
|
if (store.selected.has(key)) store.selected.delete(key)
|
||||||
|
else store.selected.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keepCursorVisibleSmooth(tr)
|
||||||
|
if (moveto === N) {
|
||||||
|
if (index > moveto) focusBreadcrumb()
|
||||||
|
else focusHeader()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length === 0) return
|
||||||
|
const scroller =
|
||||||
|
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
|
||||||
|
const currentIndex = getCursorIndex()
|
||||||
|
const currentEl = store.cursor ? getDocElement(store.cursor) : null
|
||||||
|
const currentCenter = currentEl
|
||||||
|
? currentEl.getBoundingClientRect().top +
|
||||||
|
currentEl.getBoundingClientRect().height / 2
|
||||||
|
: scroller.getBoundingClientRect().top + scroller.clientHeight / 2
|
||||||
|
const targetCenter =
|
||||||
|
currentCenter + direction * Math.max(120, scroller.clientHeight - 140)
|
||||||
|
|
||||||
|
let bestIndex = direction > 0 ? docs.length - 1 : 0
|
||||||
|
let bestDistance = Number.POSITIVE_INFINITY
|
||||||
|
for (let i = 0; i < docs.length; i++) {
|
||||||
|
if (
|
||||||
|
currentIndex !== docs.length &&
|
||||||
|
((direction > 0 && i <= currentIndex) || (direction < 0 && i >= currentIndex))
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
const el = getDocElement(docs[i]!.key)
|
||||||
|
if (!el) continue
|
||||||
|
const center =
|
||||||
|
el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2
|
||||||
|
const distance = Math.abs(center - targetCenter)
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance
|
||||||
|
bestIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(bestIndex, ev)
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
newFolder() {
|
newFolder() {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
@@ -251,9 +327,23 @@ defineExpose({
|
|||||||
markKeyboardFollow()
|
markKeyboardFollow()
|
||||||
this.cursorMove(1, ev)
|
this.cursorMove(1, ev)
|
||||||
},
|
},
|
||||||
|
pageUp(ev: KeyboardEvent) {
|
||||||
|
pageMove(-1, ev)
|
||||||
|
},
|
||||||
|
pageDown(ev: KeyboardEvent) {
|
||||||
|
pageMove(1, ev)
|
||||||
|
},
|
||||||
|
home(ev: KeyboardEvent) {
|
||||||
|
if (!props.documents.length) return
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(0, ev)
|
||||||
|
},
|
||||||
|
end(ev: KeyboardEvent) {
|
||||||
|
if (!props.documents.length) return
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(props.documents.length - 1, ev)
|
||||||
|
},
|
||||||
cursorMove(d: number, ev: KeyboardEvent | null) {
|
cursorMove(d: number, ev: KeyboardEvent | null) {
|
||||||
const select = !!ev?.shiftKey
|
|
||||||
// Move cursor up or down (keyboard navigation)
|
|
||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
if (docs.length === 0) {
|
if (docs.length === 0) {
|
||||||
store.cursor = ''
|
store.cursor = ''
|
||||||
@@ -262,7 +352,7 @@ defineExpose({
|
|||||||
const N = docs.length
|
const N = docs.length
|
||||||
const mod = (a: number, b: number) => ((a % b) + b) % b
|
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||||
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||||
const index = store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
|
const index = getCursorIndex()
|
||||||
// Stop navigation sideways away from the grid (only with up/down)
|
// Stop navigation sideways away from the grid (only with up/down)
|
||||||
if (ev && index === 0 && ev.key === 'ArrowLeft') return
|
if (ev && index === 0 && ev.key === 'ArrowLeft') return
|
||||||
if (ev && index === N - 1 && ev.key === 'ArrowRight') return
|
if (ev && index === N - 1 && ev.key === 'ArrowRight') return
|
||||||
@@ -274,26 +364,7 @@ defineExpose({
|
|||||||
// Wrapping either end, just land outside the list
|
// Wrapping either end, just land outside the list
|
||||||
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
|
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
|
||||||
}
|
}
|
||||||
store.cursor = docs[moveto]?.key ?? ''
|
moveCursorTo(moveto, ev)
|
||||||
const tr = store.cursor
|
|
||||||
? (document.getElementById(`file-${store.cursor}`) as HTMLElement | null)
|
|
||||||
: null
|
|
||||||
if (select) {
|
|
||||||
// Go forwards, possibly wrapping over the end; the last entry is not toggled
|
|
||||||
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
|
||||||
for (let p = begin; p !== end; p = increment(p, 1)) {
|
|
||||||
if (p === N) continue
|
|
||||||
const key = docs[p]!.key
|
|
||||||
if (store.selected.has(key)) store.selected.delete(key)
|
|
||||||
else store.selected.add(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keepCursorVisibleSmooth(tr)
|
|
||||||
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
|
||||||
if (moveto === N) {
|
|
||||||
if (d < 0) focusBreadcrumb()
|
|
||||||
else focusHeader()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const focusHeader = () => {
|
const focusHeader = () => {
|
||||||
@@ -306,109 +377,9 @@ const focusBreadcrumb = () => {
|
|||||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||||
if (el) el.focus()
|
if (el) el.focus()
|
||||||
}
|
}
|
||||||
let scrollAnimationFrame: number | null = null
|
const keyboardFollowScroll = createKeyboardFollowScroll()
|
||||||
let scrollTargetY: number | null = null
|
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
||||||
let scrollVelocity = 0
|
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
||||||
let keyboardFollowUntil = 0
|
|
||||||
|
|
||||||
const markKeyboardFollow = () => {
|
|
||||||
keyboardFollowUntil = performance.now() + 260
|
|
||||||
}
|
|
||||||
const keyboardFollowActive = () => performance.now() < keyboardFollowUntil
|
|
||||||
|
|
||||||
const getScrollContainer = () =>
|
|
||||||
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
|
|
||||||
|
|
||||||
const clampScrollY = (y: number, scroller: HTMLElement) => {
|
|
||||||
const maxY = Math.max(0, scroller.scrollHeight - scroller.clientHeight)
|
|
||||||
return Math.min(maxY, Math.max(0, y))
|
|
||||||
}
|
|
||||||
|
|
||||||
const cursorScrollTarget = (el: HTMLElement): number | null => {
|
|
||||||
const scroller = getScrollContainer()
|
|
||||||
const rect = el.getBoundingClientRect()
|
|
||||||
const scrollerRect = scroller.getBoundingClientRect()
|
|
||||||
const topPad = 84
|
|
||||||
const bottomPad = 84
|
|
||||||
const visibleTop = scrollerRect.top + topPad
|
|
||||||
const visibleBottom = scrollerRect.bottom - bottomPad
|
|
||||||
|
|
||||||
if (rect.top >= visibleTop && rect.bottom <= visibleBottom) return null
|
|
||||||
|
|
||||||
// Keep the cursor within a comfortable viewport band instead of forcing center.
|
|
||||||
if (rect.top < visibleTop) {
|
|
||||||
return clampScrollY(scroller.scrollTop + (rect.top - visibleTop), scroller)
|
|
||||||
}
|
|
||||||
|
|
||||||
return clampScrollY(scroller.scrollTop + (rect.bottom - visibleBottom), scroller)
|
|
||||||
}
|
|
||||||
|
|
||||||
const runSmoothCursorScroll = () => {
|
|
||||||
if (scrollAnimationFrame != null) return
|
|
||||||
|
|
||||||
const step = () => {
|
|
||||||
if (scrollTargetY == null) {
|
|
||||||
scrollVelocity *= 0.78
|
|
||||||
if (Math.abs(scrollVelocity) > 0.05) {
|
|
||||||
const scroller = getScrollContainer()
|
|
||||||
const next = clampScrollY(scroller.scrollTop + scrollVelocity, scroller)
|
|
||||||
scroller.scrollTop = next
|
|
||||||
scrollAnimationFrame = requestAnimationFrame(step)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scrollVelocity = 0
|
|
||||||
scrollAnimationFrame = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const scroller = getScrollContainer()
|
|
||||||
const current = scroller.scrollTop
|
|
||||||
const delta = scrollTargetY - current
|
|
||||||
const absDelta = Math.abs(delta)
|
|
||||||
if (absDelta < 0.6 && Math.abs(scrollVelocity) < 0.08) {
|
|
||||||
scroller.scrollTop = scrollTargetY
|
|
||||||
scrollVelocity = 0
|
|
||||||
scrollTargetY = null
|
|
||||||
scrollAnimationFrame = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spring + damping momentum model for smoother retargeting during rapid selection changes.
|
|
||||||
const stiffness = Math.min(0.03, 0.012 + absDelta / 8000)
|
|
||||||
const damping = 0.84
|
|
||||||
scrollVelocity += delta * stiffness
|
|
||||||
scrollVelocity *= damping
|
|
||||||
|
|
||||||
const next = clampScrollY(current + scrollVelocity, scroller)
|
|
||||||
if (next === current) scrollVelocity = 0
|
|
||||||
scroller.scrollTop = next
|
|
||||||
scrollAnimationFrame = requestAnimationFrame(step)
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollAnimationFrame = requestAnimationFrame(step)
|
|
||||||
}
|
|
||||||
|
|
||||||
const keepCursorVisibleSmooth = (el: HTMLElement | null) => {
|
|
||||||
if (!keyboardFollowActive()) {
|
|
||||||
scrollTargetY = null
|
|
||||||
scrollVelocity = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!el) {
|
|
||||||
scrollTargetY = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = cursorScrollTarget(el)
|
|
||||||
if (target == null) {
|
|
||||||
scrollTargetY = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollTargetY = target
|
|
||||||
runSmoothCursorScroll()
|
|
||||||
}
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
||||||
if (editing.value) store.cursor = editing.value.key
|
if (editing.value) store.cursor = editing.value.key
|
||||||
@@ -442,9 +413,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (scrollAnimationFrame != null) cancelAnimationFrame(scrollAnimationFrame)
|
keyboardFollowScroll.cancel()
|
||||||
scrollAnimationFrame = null
|
|
||||||
scrollTargetY = null
|
|
||||||
resizeObserver?.disconnect()
|
resizeObserver?.disconnect()
|
||||||
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
|
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
>
|
>
|
||||||
<figure>
|
<figure>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
<MediaPreview ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" />
|
<MediaPreview :key="snap.ext" ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" />
|
||||||
<div class="titlespacer"></div>
|
<div class="titlespacer"></div>
|
||||||
<figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)">
|
<figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)">
|
||||||
<template v-if="editing">
|
<template v-if="editing">
|
||||||
@@ -26,8 +26,8 @@
|
|||||||
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
||||||
<div class="filename-row">
|
<div class="filename-row">
|
||||||
<span class="filename-group">
|
<span class="filename-group">
|
||||||
<span class="filename">{{ displayName }}<SparseIndicator :doc="doc" class="after-name" /></span>
|
<span class="filename">{{ snap.displayName }}<SparseIndicator :doc="doc" class="after-name" /></span>
|
||||||
<span v-if="doc.ext" class="file-ext">.{{ doc.ext }}</span>
|
<span v-if="snap.ext" class="file-ext">.{{ snap.ext }}</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="rename-btn" @click="$emit('rename')" title="Rename">✏️</button>
|
<button class="rename-btn" @click="$emit('rename')" title="Rename">✏️</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
<CursorTooltip ref="tooltip" :text="tooltipText">
|
<CursorTooltip ref="tooltip" :text="tooltipText">
|
||||||
<div class="tooltip-name">{{ doc.name }}</div>
|
<div class="tooltip-name">{{ snap.name }}</div>
|
||||||
<div class="tooltip-details">{{ doc.modified }} — {{ doc.sizedisp }}</div>
|
<div class="tooltip-details">{{ doc.modified }} — {{ doc.sizedisp }}</div>
|
||||||
<div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div>
|
<div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div>
|
||||||
</CursorTooltip>
|
</CursorTooltip>
|
||||||
@@ -72,10 +72,18 @@ const sparseText = computed(() => {
|
|||||||
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const displayName = computed(() => {
|
// Single subscription to docVersion; all doc-derived values come from here.
|
||||||
|
// This is needed because Doc instances are non-reactive plain objects, so
|
||||||
|
// mutating doc.name alone won't invalidate computed caches.
|
||||||
|
const snap = computed(() => {
|
||||||
|
void store.docVersion
|
||||||
const { name, ext } = props.doc
|
const { name, ext } = props.doc
|
||||||
const base = ext ? name.slice(0, name.length - ext.length - 1) : name
|
const base = ext ? name.slice(0, name.length - ext.length - 1) : name
|
||||||
return base.replace(/[_.]+/g, ' ')
|
return {
|
||||||
|
name,
|
||||||
|
ext,
|
||||||
|
displayName: base.replace(/[_.]+/g, ' ')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const onclick = (ev: Event) => {
|
const onclick = (ev: Event) => {
|
||||||
|
|||||||
@@ -253,6 +253,12 @@ export const useMainStore = defineStore('main', {
|
|||||||
}))
|
}))
|
||||||
worker.postMessage({ type: 'update', documents: docData })
|
worker.postMessage({ type: 'update', documents: docData })
|
||||||
},
|
},
|
||||||
|
/** Notify UI/search that existing document objects were mutated in-place */
|
||||||
|
documentsChanged() {
|
||||||
|
triggerUpdate()
|
||||||
|
this.docVersion++
|
||||||
|
this.syncSearchWorker()
|
||||||
|
},
|
||||||
search(query: string, loc: string) {
|
search(query: string, loc: string) {
|
||||||
const worker = getSearchWorker()
|
const worker = getSearchWorker()
|
||||||
const id = ++searchId
|
const id = ++searchId
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
type ScrollOptions = {
|
||||||
|
topPad?: number
|
||||||
|
bottomPad?: number
|
||||||
|
keyboardWindowMs?: number
|
||||||
|
getScrollContainer?: () => HTMLElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createKeyboardFollowScroll(options: ScrollOptions = {}) {
|
||||||
|
const {
|
||||||
|
topPad = 84,
|
||||||
|
bottomPad = 84,
|
||||||
|
keyboardWindowMs = 260,
|
||||||
|
getScrollContainer = () =>
|
||||||
|
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
|
||||||
|
} = options
|
||||||
|
|
||||||
|
let scrollAnimationFrame: number | null = null
|
||||||
|
let scrollTargetY: number | null = null
|
||||||
|
let scrollVelocity = 0
|
||||||
|
let keyboardFollowUntil = 0
|
||||||
|
|
||||||
|
const markKeyboardFollow = () => {
|
||||||
|
keyboardFollowUntil = performance.now() + keyboardWindowMs
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyboardFollowActive = () => performance.now() < keyboardFollowUntil
|
||||||
|
|
||||||
|
const clampScrollY = (y: number, scroller: HTMLElement) => {
|
||||||
|
const maxY = Math.max(0, scroller.scrollHeight - scroller.clientHeight)
|
||||||
|
return Math.min(maxY, Math.max(0, y))
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursorScrollTarget = (el: HTMLElement): number | null => {
|
||||||
|
const scroller = getScrollContainer() ?? document.documentElement
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const scrollerRect = scroller.getBoundingClientRect()
|
||||||
|
const visibleTop = scrollerRect.top + topPad
|
||||||
|
const visibleBottom = scrollerRect.bottom - bottomPad
|
||||||
|
|
||||||
|
if (rect.top >= visibleTop && rect.bottom <= visibleBottom) return null
|
||||||
|
|
||||||
|
if (rect.top < visibleTop) {
|
||||||
|
return clampScrollY(scroller.scrollTop + (rect.top - visibleTop), scroller)
|
||||||
|
}
|
||||||
|
|
||||||
|
return clampScrollY(scroller.scrollTop + (rect.bottom - visibleBottom), scroller)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runSmoothCursorScroll = () => {
|
||||||
|
if (scrollAnimationFrame != null) return
|
||||||
|
|
||||||
|
const step = () => {
|
||||||
|
const scroller = getScrollContainer() ?? document.documentElement
|
||||||
|
|
||||||
|
if (scrollTargetY == null) {
|
||||||
|
scrollVelocity *= 0.68
|
||||||
|
if (Math.abs(scrollVelocity) > 0.05) {
|
||||||
|
const next = clampScrollY(scroller.scrollTop + scrollVelocity, scroller)
|
||||||
|
scroller.scrollTop = next
|
||||||
|
scrollAnimationFrame = requestAnimationFrame(step)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scrollVelocity = 0
|
||||||
|
scrollAnimationFrame = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = scroller.scrollTop
|
||||||
|
const delta = scrollTargetY - current
|
||||||
|
const absDelta = Math.abs(delta)
|
||||||
|
if (absDelta < 0.6 && Math.abs(scrollVelocity) < 0.08) {
|
||||||
|
scroller.scrollTop = scrollTargetY
|
||||||
|
scrollVelocity = 0
|
||||||
|
scrollTargetY = null
|
||||||
|
scrollAnimationFrame = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const stiffness = Math.min(0.022, 0.01 + absDelta / 10000)
|
||||||
|
const damping = 0.76
|
||||||
|
scrollVelocity += delta * stiffness
|
||||||
|
scrollVelocity *= damping
|
||||||
|
|
||||||
|
const next = clampScrollY(current + scrollVelocity, scroller)
|
||||||
|
if (next === current) scrollVelocity = 0
|
||||||
|
scroller.scrollTop = next
|
||||||
|
scrollAnimationFrame = requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollAnimationFrame = requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepVisible = (el: HTMLElement | null) => {
|
||||||
|
if (!keyboardFollowActive()) {
|
||||||
|
scrollTargetY = null
|
||||||
|
scrollVelocity = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!el) {
|
||||||
|
scrollTargetY = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = cursorScrollTarget(el)
|
||||||
|
if (target == null) {
|
||||||
|
scrollTargetY = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollTargetY = target
|
||||||
|
runSmoothCursorScroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
if (scrollAnimationFrame != null) cancelAnimationFrame(scrollAnimationFrame)
|
||||||
|
scrollAnimationFrame = null
|
||||||
|
scrollTargetY = null
|
||||||
|
scrollVelocity = 0
|
||||||
|
keyboardFollowUntil = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return { markKeyboardFollow, keepVisible, cancel }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user