Files
cista-storage/frontend/src/components/Gallery.vue
T
LeoVasanko 49232f11cc Fix rename flow: KeepAlive-cached view watchers cleared cursor on stale props
Deactivated FileExplorer/Gallery instances stay alive in KeepAlive with
frozen, potentially empty document props. Their empty-folder watcher
cleared store.cursor and yanked focus to the breadcrumb on every cursor
change, breaking rename via gallery pen and keyboard entry into the
file list, and hiding the explorer rename button.

- Guard cursor watchers in FileExplorer/Gallery with an isActive flag
  (set on activated, cleared on deactivated)
- Declare emits in GalleryFigure (rename/menu fell through to the root
  anchor as native listeners)
- Show the explorer rename button on row hover with a delayed fade-in
  instead of only on the keyboard-focused row
2026-07-28 02:16:23 +00:00

650 lines
19 KiB
Vue

<template>
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: createItem, exit}" />
<template v-for="(doc, index) in documents" :key=doc.key>
<BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
<GalleryFigure
:doc=doc
:editing="editing === doc ? {rename, exit} : null"
:style="{ '--gallery-figure-height': rowHeightsByKey[doc.key] ?? '15em' }"
@menu="contextMenu($event, doc)"
@rename="onFigureRename(doc)"
:class="{ 'folder-start': showFolderBreadcrumb(index) }"
/>
</template>
</div>
<EmptyFolder v-else :documents="documents" :path="props.path" />
</template>
<script setup lang="ts">
import { apiFetch } from '@/repositories/Client'
import { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main'
import type { SortOrder } from '@/utils/docsort'
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
import ContextMenu from '@imengyu/vue3-context-menu'
import {
computed,
nextTick,
onActivated,
onDeactivated,
onMounted,
onUnmounted,
ref,
shallowRef,
watch
} from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps<{
path: Array<string>
documents: Doc[]
}>()
const store = useMainStore()
const router = useRouter()
const filesUrl = (path: string) =>
'/files/' +
path
.split('/')
.map(part => encodeURIComponent(part))
.join('/')
const parseErrorMessage = async (res: Response) => {
try {
const data = await res.json()
return data.message || data.detail || `${res.status} ${res.statusText}`
} catch {
return `${res.status} ${res.statusText}`
}
}
// File rename
const editing = shallowRef<Doc | null>(null)
const exit = () => {
editing.value = null
}
const onFigureRename = (doc: Doc) => {
editing.value = doc
store.cursor = doc.key
}
const rename = async (doc: Doc, newName: string) => {
const oldName = doc.name
doc.name = newName // We should get an update from watch but this is quicker
store.documentsChanged()
try {
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
const targetUrl = `${dstUrl}${dstUrl.endsWith('/') ? '' : '/'}${encodeURIComponent(newName)}`
const res = await apiFetch(`${targetUrl}?mv=${doc.key}`, { method: 'POST' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
} catch (err) {
console.error('Rename failed', err)
doc.name = oldName
store.documentsChanged()
store.showToast(err instanceof Error ? err.message : 'Rename failed')
}
}
const gallery = ref<HTMLElement>()
const columnCount = ref(1)
const columnWidthPx = ref(240)
const emPx = ref(16)
const aspectByKey = ref<Record<string, number>>({})
const optimalRowHeightPx = (ratios: number[]) => {
const w = Math.max(1, columnWidthPx.value)
const minH = Math.max(1, Math.round(7 * emPx.value))
const maxH = Math.max(minH, Math.round(30 * emPx.value))
const usable = ratios.filter(ar => Number.isFinite(ar) && ar > 0)
if (usable.length === 0) return Math.round(15 * emPx.value)
let bestH = Math.round(15 * emPx.value)
let bestScore = -1
for (let h = minH; h <= maxH; h++) {
let score = 0
for (const ar of usable) {
let shownW = w
let shownH = w * ar
if (shownH > h) {
shownH = h
shownW = h / ar
}
// Fill efficiency in the row cell (0..1)
score += (shownW * shownH) / (w * h)
}
if (score > bestScore) {
bestScore = score
bestH = h
}
}
return bestH
}
const setAspect = (key: string, ar: number) => {
if (!Number.isFinite(ar) || ar <= 0) return
if (aspectByKey.value[key] === ar) return
aspectByKey.value = {
...aspectByKey.value,
[key]: ar
}
}
const rowHeightsByKey = computed<Record<string, string>>(() => {
const docs = props.documents
const cols = Math.max(1, columnCount.value)
const byKey = aspectByKey.value
const out: Record<string, string> = {}
const assignRows = (group: Doc[]) => {
for (let start = 0; start < group.length; start += cols) {
const row = group.slice(start, start + cols)
const ratios = row
.filter(doc => doc.previewable)
.map(doc => byKey[doc.key])
.filter((ar): ar is number => ar != null)
const height = `${optimalRowHeightPx(ratios)}px`
for (const doc of row) out[doc.key] = height
}
}
let group: Doc[] = []
for (let i = 0; i < docs.length; i++) {
if (i > 0 && docs[i]!.loc !== docs[i - 1]!.loc) {
assignRows(group)
group = []
}
group.push(docs[i]!)
}
assignRows(group)
return out
})
// Seed collected ratios from server-provided ar values on docs
const seedFromDocs = () => {
for (const doc of props.documents)
if (doc.previewable && doc.ar != null) setAspect(doc.key, doc.ar)
}
const onImgLoad = (e: Event) => {
const img = e.target as HTMLImageElement
if (img.tagName !== 'IMG' || img.naturalWidth === 0) return
const anchor = img.closest('a[id^="file-"]') as HTMLAnchorElement | null
if (!anchor) return
const key = anchor.id.slice('file-'.length)
if (!key) return
setAspect(key, img.naturalHeight / img.naturalWidth)
}
const updateColumns = () => {
if (!gallery.value) return
if (gallery.value.getBoundingClientRect().width <= 0) return
const style = getComputedStyle(gallery.value)
const templates = style.gridTemplateColumns
.split(' ')
.filter(part => !!part && part !== 'none')
columnCount.value = Math.max(1, templates.length)
const first = templates[0]
if (first && first.endsWith('px')) {
const parsed = Number.parseFloat(first)
if (Number.isFinite(parsed) && parsed > 0) columnWidthPx.value = parsed
}
const parsedEm = Number.parseFloat(style.fontSize)
if (Number.isFinite(parsedEm) && parsedEm > 0) emPx.value = parsedEm
}
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({
newFile() {
const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({
loc: loc.value,
key: 'new',
name: 'New File.txt',
dir: false,
mtime: now,
size: 0,
allocated: 0
})
store.cursor = editing.value.key
},
newFolder() {
const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({
loc: loc.value,
key: 'new',
name: 'New Folder',
dir: true,
mtime: now,
size: 0,
allocated: 0
})
store.cursor = editing.value.key
},
toggleSelectAll() {
console.log('Select')
allSelected.value = !allSelected.value
},
toggleSortColumn(column: number) {
const order = ['', 'name', 'modified', 'size', ''][column]
if (order) store.toggleSort(order as SortOrder)
},
isCursor() {
return store.cursor && editing.value === null
},
focusFirst() {
const docs = props.documents
if (docs.length > 0) {
store.cursor = docs[0]!.key
// Also focus the element directly (post-flush watcher won't trigger if cursor unchanged)
nextTick(() => {
const a = document.querySelector(
`#file-${store.cursor}`
) as HTMLAnchorElement | null
if (a) a.focus()
})
}
},
cursorRename() {
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
},
cursorSelect() {
const key = store.cursor
if (!key) return
if (store.selected.has(key)) {
store.selected.delete(key)
} else {
store.selected.add(key)
}
markKeyboardFollow()
this.cursorMove(1, null)
},
up(ev: KeyboardEvent) {
markKeyboardFollow()
this.cursorMove(-columns.value, ev)
},
down(ev: KeyboardEvent) {
markKeyboardFollow()
this.cursorMove(columns.value, ev)
},
left(ev: KeyboardEvent) {
markKeyboardFollow()
this.cursorMove(-1, ev)
},
right(ev: KeyboardEvent) {
markKeyboardFollow()
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) {
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()
// Stop navigation sideways away from the grid (only with up/down)
if (ev && index === 0 && ev.key === 'ArrowLeft') return
if (ev && index === N - 1 && ev.key === 'ArrowRight') return
// Calculate new position
let moveto
if (index === N) moveto = d > 0 ? 0 : N - 1
else {
moveto = increment(index, d)
// Wrapping either end, just land outside the list
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
}
moveCursorTo(moveto, ev)
}
})
const focusHeader = () => {
const el = document.querySelector(
'.headermain input[type="search"]'
) as HTMLElement | null
if (el) el.focus()
}
const focusBreadcrumb = () => {
const el = document.querySelector('.breadcrumb') as HTMLElement | null
if (el) el.focus()
}
const keyboardFollowScroll = createKeyboardFollowScroll()
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
// Deactivated (KeepAlive-cached) instances stay alive with frozen, potentially
// stale props - their watchers must not react to global store changes.
let isActive = true
watch(
() => store.cursor,
cursor => {
if (!isActive) return
if (cursor && editing.value && cursor !== editing.value.key) {
exit()
}
}
)
watch(
() => store.cursor,
cursor => {
if (!isActive) return
if (cursor && !editing.value) {
const a = document.querySelector(`#file-${cursor}`) as HTMLAnchorElement | null
if (a) {
a.focus({ preventScroll: true })
}
}
},
{ flush: 'post' }
)
watch(
() => [props.documents.length, store.cursor, store.query, editing.value] as const,
([len, cursor, query, editingDoc]) => {
if (!isActive) return
if (!len && cursor && !query && !editingDoc) {
store.cursor = ''
focusBreadcrumb()
}
}
)
let resizeObserver: ResizeObserver | null = null
const attachGalleryObservers = () => {
if (!gallery.value || resizeObserver) return
resizeObserver = new ResizeObserver(updateColumns)
resizeObserver.observe(gallery.value)
gallery.value.addEventListener('load', onImgLoad, { capture: true })
}
const detachGalleryObservers = () => {
resizeObserver?.disconnect()
resizeObserver = null
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
}
onMounted(() => {
const active = document.querySelector('.cursor') as HTMLElement | null
if (active) {
active.focus({ preventScroll: true })
}
updateColumns()
seedFromDocs()
attachGalleryObservers()
})
onActivated(() => {
isActive = true
nextTick(() => {
updateColumns()
attachGalleryObservers()
})
})
onDeactivated(() => {
isActive = false
detachGalleryObservers()
if (editing.value) exit()
})
onUnmounted(() => {
keyboardFollowScroll.cancel()
detachGalleryObservers()
})
// Re-seed aspect ratios whenever docs update (e.g., ar patch from server)
watch(() => props.documents, seedFromDocs)
const editRoute = (path: string) =>
'/' +
path
.split('/')
.map(part => encodeURIComponent(part))
.join('/')
const createItem = async (doc: Doc, name: string) => {
doc.name = name
doc.key = crypto.randomUUID()
store.addGhost(doc)
store.cursor = doc.key
exit()
const path = doc.loc ? `${doc.loc}/${name}` : name
try {
const res = doc.dir
? await apiFetch(filesUrl(path), { method: 'MKCOL' })
: await apiFetch(filesUrl(path), {
method: 'PUT',
body: '',
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
if (!res.ok) throw new Error(await parseErrorMessage(res))
if (doc.dir) {
router.push(doc.urlrouter)
} else {
router.push(editRoute(path))
}
} catch (err) {
console.error('Create failed', err)
store.showToast(err instanceof Error ? err.message : 'Create failed')
}
}
const showFolderBreadcrumb = (i: number) => {
const docs = props.documents
const docloc = docs[i]!.loc
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1]!.loc
}
const allSelected = computed({
get: () => {
return (
props.documents.length > 0 &&
props.documents.every((doc: Doc) => store.selected.has(doc.key))
)
},
set: (value: boolean) => {
console.log('Setting allSelected', value)
for (const doc of props.documents) {
if (value) {
store.selected.add(doc.key)
} else {
store.selected.delete(doc.key)
}
}
}
})
const loc = computed(() => props.path.join('/'))
const downloadFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
if (doc.dir) {
// Download folder as ZIP
const a = document.createElement('a')
a.href = `/zip/${doc.key}/${doc.name}.zip`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}.zip`)
} else {
// Download single file
const a = document.createElement('a')
a.href = `/files/${path}`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}`)
}
}
const copyLink = async (doc: Doc) => {
const url = new URL(doc.url, window.location.origin).href
try {
await navigator.clipboard.writeText(url)
store.showToast('📋 Link copied!')
} catch {
store.showToast('Failed to copy link')
}
}
const copyImage = async (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
try {
store.showToast('Copying image...')
const res = await fetch(`/files/${path}`)
const blob = await res.blob()
// Convert to PNG if needed (clipboard only supports PNG)
if (blob.type !== 'image/png') {
const img = new Image()
img.src = URL.createObjectURL(blob)
await new Promise(r => (img.onload = r))
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
canvas.getContext('2d')!.drawImage(img, 0, 0)
const pngBlob = await new Promise<Blob>(r =>
canvas.toBlob(b => r(b!), 'image/png')
)
URL.revokeObjectURL(img.src)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
} else {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
}
store.showToast('📋 Image copied!')
} catch (e) {
console.error('Copy image failed', e)
store.showToast('Failed to copy image')
}
}
const deleteFile = async (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
store.hideDoc(path)
try {
const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
store.showToast(`🗑️ Deleted ${doc.name}`)
} catch (err) {
console.error('Delete failed', err)
store.unhideDoc(path)
store.showToast(err instanceof Error ? err.message : 'Delete failed')
}
}
const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key
const items = [
{ label: '📥 Download', onClick: () => downloadFile(doc) },
{ label: '🔗 Copy Link', onClick: () => copyLink(doc) }
]
if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
items.push(
{
label: '✏️ Rename',
onClick: () => {
editing.value = doc
}
},
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) }
)
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
}
</script>
<style scoped>
.gallery {
padding: 1em;
width: 100%;
display: grid;
gap: .5em;
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
align-items: start;
align-content: start;
}
.folder-indicator {
grid-column: 1 / -1;
}
.folder-start {
grid-column-start: 1;
}
</style>