Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07089aa9a7 | ||
|
|
c3146744b7 | ||
|
|
48d7435d0b | ||
|
|
3e80325053 | ||
|
|
31fc02ddbf | ||
|
|
2406ea87b0 |
+46
-7
@@ -8,6 +8,7 @@
|
|||||||
<SettingsModal />
|
<SettingsModal />
|
||||||
<UserManagementModal />
|
<UserManagementModal />
|
||||||
<UserTokensModal />
|
<UserTokensModal />
|
||||||
|
<AboutModal />
|
||||||
<AccessDeniedModal />
|
<AccessDeniedModal />
|
||||||
<header>
|
<header>
|
||||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
|
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
|
||||||
@@ -28,11 +29,12 @@ import type HeaderMain from '@/components/HeaderMain.vue'
|
|||||||
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import type { ComputedRef } from 'vue'
|
import type { ComputedRef } from 'vue'
|
||||||
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
|
|
||||||
import Router from '@/router/index'
|
import Router from '@/router/index'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import AboutModal from './components/AboutModal.vue'
|
||||||
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
||||||
import SelectionToolbar from './components/SelectionToolbar.vue'
|
import SelectionToolbar from './components/SelectionToolbar.vue'
|
||||||
import type SettingsModalVue from './components/SettingsModal.vue'
|
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||||
@@ -56,12 +58,16 @@ const path: ComputedRef<Path> = computed(() => {
|
|||||||
query
|
query
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
watchEffect(() => {
|
watch(
|
||||||
document.title =
|
() => path.value.path,
|
||||||
path.value.path.replace(/\/$/, '').split('/').pop() ||
|
() => {
|
||||||
store.server.name ||
|
document.title =
|
||||||
'Cista Storage'
|
path.value.path.replace(/\/$/, '').split('/').pop() ||
|
||||||
})
|
store.server.name ||
|
||||||
|
'Cista Storage'
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
onMounted(loadSession)
|
onMounted(loadSession)
|
||||||
onMounted(watchConnect)
|
onMounted(watchConnect)
|
||||||
onUnmounted(watchDisconnect)
|
onUnmounted(watchDisconnect)
|
||||||
@@ -95,6 +101,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 +112,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
|
||||||
@@ -115,10 +124,22 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
if (searchHasText && (dir === 'left' || dir === 'right')) {
|
if (searchHasText && (dir === 'left' || dir === 'right')) {
|
||||||
return // Let browser handle cursor movement
|
return // Let browser handle cursor movement
|
||||||
}
|
}
|
||||||
|
// Don't intercept arrows for non-search inputs (e.g. rename input)
|
||||||
|
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)) {
|
||||||
@@ -136,6 +157,8 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
else if (keyup && event.key === 'Escape') {
|
else if (keyup && event.key === 'Escape') {
|
||||||
store.error = ''
|
store.error = ''
|
||||||
store.clearToast()
|
store.clearToast()
|
||||||
|
// Keep rename and other non-search inputs isolated from search behavior.
|
||||||
|
if (input && !searchInput) return
|
||||||
headerMain.value!.clearSearch(event)
|
headerMain.value!.clearSearch(event)
|
||||||
store.focusBreadcrumb()
|
store.focusBreadcrumb()
|
||||||
} else if (!input && keyup && event.key === 'Backspace') {
|
} else if (!input && keyup && event.key === 'Backspace') {
|
||||||
@@ -235,12 +258,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)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" fill="#f80"/><path fill="#fff" d="M381 298h-84V167h-66L339 35l108 132h-66zm-168-84h-84v131H63l108 132 108-132h-66z"/></svg>
|
||||||
|
After Width: | Height: | Size: 242 B |
@@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<ModalDialog name="about" title="">
|
||||||
|
<div class="about-content">
|
||||||
|
<div class="about-logo-pane">
|
||||||
|
<img :src="logoUrl" alt="Cista Storage logo" class="about-logo" />
|
||||||
|
</div>
|
||||||
|
<div class="about-details">
|
||||||
|
<h3 class="about-name">Cista {{ softwareVersion }}</h3>
|
||||||
|
<p class="about-link">
|
||||||
|
<a :href="projectUrl" target="_blank" rel="noopener noreferrer">{{ displayProjectUrl }}</a>
|
||||||
|
</p>
|
||||||
|
<div class="dialog-buttons about-actions">
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<input id="close" type="reset" value="Close" class="button" @click="close" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import logoUrl from '@/assets/logo-square.svg?url'
|
||||||
|
import ModalDialog from '@/components/ModalDialog.vue'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
|
||||||
|
const softwareVersion = computed(() => store.server.version || 'unknown')
|
||||||
|
const projectUrl = 'https://git.zi.fi/Vasanko/cista-storage'
|
||||||
|
const displayProjectUrl = projectUrl.replace(/^https?:\/\//, '')
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
store.dialog = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(#about.modal-dialog) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 11rem minmax(0, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
width: min(35rem, 92vw);
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
margin: -1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo-pane {
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
margin: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-name {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link {
|
||||||
|
margin: 0.65rem 0 1rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-actions {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
.about-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
width: min(24rem, 90vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo-pane {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-details {
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,32 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="store.dialog === 'accessdenied'" class="modal-overlay">
|
<ModalDialog name="accessdenied" title="">
|
||||||
<div class="modal-dialog" id="accessdenied">
|
<div class="access-denied">
|
||||||
<div class="modal-content access-denied">
|
<p class="icon">⛔</p>
|
||||||
<p class="icon">⛔</p>
|
<p class="message">Access Denied</p>
|
||||||
<p class="message">Access Denied</p>
|
<button @click="reload" class="button">Reload</button>
|
||||||
<button @click="reload" class="button">Reload</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMainStore } from '@/stores/main'
|
import ModalDialog from '@/components/ModalDialog.vue'
|
||||||
import { holdGlobalBackdrop } from 'paskia'
|
|
||||||
import { watchEffect } from 'vue'
|
|
||||||
|
|
||||||
const store = useMainStore()
|
|
||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
location.reload()
|
location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep backdrop active when this dialog shows
|
|
||||||
watchEffect(() => {
|
|
||||||
if (store.dialog === 'accessdenied') {
|
|
||||||
holdGlobalBackdrop()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
:editing="editing === doc ? {rename, exit} : null"
|
:editing="editing === doc ? {rename, exit} : null"
|
||||||
:style="{ '--gallery-figure-height': rowHeightsByKey[doc.key] ?? '15em' }"
|
:style="{ '--gallery-figure-height': rowHeightsByKey[doc.key] ?? '15em' }"
|
||||||
@menu="contextMenu($event, doc)"
|
@menu="contextMenu($event, doc)"
|
||||||
|
@rename="editing = doc; store.cursor = doc.key"
|
||||||
:class="{ 'folder-start': showFolderBreadcrumb(index) }"
|
:class="{ 'folder-start': showFolderBreadcrumb(index) }"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -19,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,
|
||||||
@@ -63,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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,7 +87,7 @@ const aspectByKey = ref<Record<string, number>>({})
|
|||||||
const optimalRowHeightPx = (ratios: number[]) => {
|
const optimalRowHeightPx = (ratios: number[]) => {
|
||||||
const w = Math.max(1, columnWidthPx.value)
|
const w = Math.max(1, columnWidthPx.value)
|
||||||
const minH = Math.max(1, Math.round(7 * emPx.value))
|
const minH = Math.max(1, Math.round(7 * emPx.value))
|
||||||
const maxH = Math.max(minH, Math.round(25 * emPx.value))
|
const maxH = Math.max(minH, Math.round(30 * emPx.value))
|
||||||
const usable = ratios.filter(ar => Number.isFinite(ar) && ar > 0)
|
const usable = ratios.filter(ar => Number.isFinite(ar) && ar > 0)
|
||||||
if (usable.length === 0) return Math.round(15 * emPx.value)
|
if (usable.length === 0) return Math.round(15 * emPx.value)
|
||||||
|
|
||||||
@@ -182,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)
|
||||||
@@ -231,23 +308,42 @@ 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(-columns.value, ev)
|
this.cursorMove(-columns.value, ev)
|
||||||
},
|
},
|
||||||
down(ev: KeyboardEvent) {
|
down(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(columns.value, ev)
|
this.cursorMove(columns.value, ev)
|
||||||
},
|
},
|
||||||
left(ev: KeyboardEvent) {
|
left(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(-1, ev)
|
this.cursorMove(-1, ev)
|
||||||
},
|
},
|
||||||
right(ev: KeyboardEvent) {
|
right(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)
|
||||||
|
},
|
||||||
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 = ''
|
||||||
@@ -256,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
|
||||||
@@ -268,31 +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}`) : ''
|
|
||||||
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 = () => {
|
||||||
@@ -305,18 +377,18 @@ 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
|
||||||
if (store.cursor) {
|
if (store.cursor && !editing.value) {
|
||||||
const a = document.querySelector(
|
const a = document.querySelector(
|
||||||
`#file-${store.cursor}`
|
`#file-${store.cursor}`
|
||||||
) as HTMLAnchorElement | null
|
) as HTMLAnchorElement | null
|
||||||
if (a) {
|
if (a) {
|
||||||
a.focus()
|
a.focus({ preventScroll: true })
|
||||||
a.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -330,8 +402,7 @@ let resizeObserver: ResizeObserver | null = null
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
updateColumns()
|
updateColumns()
|
||||||
seedFromDocs()
|
seedFromDocs()
|
||||||
@@ -342,6 +413,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
keyboardFollowScroll.cancel()
|
||||||
resizeObserver?.disconnect()
|
resizeObserver?.disconnect()
|
||||||
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
|
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,21 +10,33 @@
|
|||||||
>
|
>
|
||||||
<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">
|
||||||
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit />
|
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
||||||
|
<div class="filename-row rename-row">
|
||||||
|
<div class="rename-wrap">
|
||||||
|
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class=namespacer></div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
||||||
<span>{{ doc.name }}<SparseIndicator :doc="doc" class="after-name" /></span>
|
<div class="filename-row">
|
||||||
|
<span class="filename-group">
|
||||||
|
<span class="filename">{{ snap.displayName }}<SparseIndicator :doc="doc" class="after-name" /></span>
|
||||||
|
<span v-if="snap.ext" class="file-ext">.{{ snap.ext }}</span>
|
||||||
|
</span>
|
||||||
|
<button class="rename-btn" @click="$emit('rename')" title="Rename">✏️</button>
|
||||||
|
</div>
|
||||||
<div class=namespacer></div>
|
<div class=namespacer></div>
|
||||||
</template>
|
</template>
|
||||||
</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>
|
||||||
@@ -42,7 +54,7 @@ import SparseIndicator from './SparseIndicator.vue'
|
|||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
type EditingProp = {
|
type EditingProp = {
|
||||||
rename: (name: string) => void
|
rename: (doc: Doc, newName: string) => void
|
||||||
exit: () => void
|
exit: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +72,20 @@ const sparseText = computed(() => {
|
|||||||
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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 base = ext ? name.slice(0, name.length - ext.length - 1) : name
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
ext,
|
||||||
|
displayName: base.replace(/[_.]+/g, ' ')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const onclick = (ev: Event) => {
|
const onclick = (ev: Event) => {
|
||||||
if (m.value!.play()) ev.preventDefault()
|
if (m.value!.play()) ev.preventDefault()
|
||||||
store.cursor = props.doc.key
|
store.cursor = props.doc.key
|
||||||
@@ -81,6 +107,75 @@ const onclick = (ev: Event) => {
|
|||||||
.after-name {
|
.after-name {
|
||||||
margin-left: 0.3em;
|
margin-left: 0.3em;
|
||||||
}
|
}
|
||||||
|
.filename-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
max-width: calc(100% - 4.5em);
|
||||||
|
}
|
||||||
|
.filename-row::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 100%;
|
||||||
|
top: 0;
|
||||||
|
width: 1.4em;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.filename-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.filename {
|
||||||
|
cursor: default;
|
||||||
|
padding: .5em 0;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.file-ext {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
|
||||||
|
padding: 0 .15em 0 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.rename-btn {
|
||||||
|
position: absolute;
|
||||||
|
left: 100%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(0.2em, -50%);
|
||||||
|
z-index: 2;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.12s ease;
|
||||||
|
}
|
||||||
|
.filename-row:hover .rename-btn {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
figure {
|
figure {
|
||||||
height: var(--gallery-figure-height, 15em);
|
height: var(--gallery-figure-height, 15em);
|
||||||
max-height: var(--gallery-figure-height, 15em);
|
max-height: var(--gallery-figure-height, 15em);
|
||||||
@@ -126,17 +221,10 @@ figcaption input[type='checkbox'] {
|
|||||||
figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] {
|
figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
figcaption span {
|
.cursor .filename {
|
||||||
cursor: default;
|
color: var(--accent-color);
|
||||||
padding: .5em;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
|
|
||||||
text-wrap: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.cursor figcaption span {
|
.cursor .file-ext {
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
figcaption .namespacer {
|
figcaption .namespacer {
|
||||||
@@ -144,4 +232,17 @@ figcaption .namespacer {
|
|||||||
height: 2em;
|
height: 2em;
|
||||||
width: 2em;
|
width: 2em;
|
||||||
}
|
}
|
||||||
|
.rename-wrap {
|
||||||
|
font-size: 0.8em;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.rename-row {
|
||||||
|
max-width: calc(100% - 4.5em);
|
||||||
|
}
|
||||||
|
.rename-wrap :deep(#FileRenameInput) {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -164,6 +164,14 @@ const settingsMenu = (e: Event) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
label: 'ℹ️ About Cista...',
|
||||||
|
onClick: () => {
|
||||||
|
store.dialog = 'about'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ContextMenu.showContextMenu({
|
ContextMenu.showContextMenu({
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
x: e.target.getBoundingClientRect().right,
|
x: e.target.getBoundingClientRect().right,
|
||||||
|
|||||||
@@ -15,15 +15,55 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||||
import { nextTick, ref, watchEffect } from 'vue'
|
import { nextTick, onBeforeUnmount, ref, watch, watchEffect } from 'vue'
|
||||||
|
|
||||||
const overlay = ref<HTMLDivElement | null>(null)
|
const overlay = ref<HTMLDivElement | null>(null)
|
||||||
const dialog = ref<HTMLDivElement | null>(null)
|
const dialog = ref<HTMLDivElement | null>(null)
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
let backdropHeld = false
|
||||||
|
|
||||||
|
const ensureGlobalBackdropStyles = () => {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
if (document.getElementById('paskia-dialog')) return
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.id = 'paskia-dialog'
|
||||||
|
style.textContent = `body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1099;
|
||||||
|
background: transparent;
|
||||||
|
backdrop-filter: blur(0) brightness(1);
|
||||||
|
-webkit-backdrop-filter: blur(0) brightness(1);
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.2s ease-out;
|
||||||
|
}
|
||||||
|
body.paskia-backdrop::before {
|
||||||
|
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
|
||||||
|
backdrop-filter: blur(.2rem) brightness(0.5);
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
body.paskia-backdrop {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
#paskia-iframe {
|
||||||
|
border: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 9999;
|
||||||
|
color-scheme: auto;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.insertBefore(style, document.head.firstChild)
|
||||||
|
}
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
store.dialog = ''
|
store.dialog = ''
|
||||||
releaseGlobalBackdrop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -33,7 +73,6 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const show = () => {
|
const show = () => {
|
||||||
store.dialog = props.name
|
store.dialog = props.name
|
||||||
holdGlobalBackdrop()
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
overlay.value?.focus()
|
overlay.value?.focus()
|
||||||
const input = dialog.value?.querySelector('input')
|
const input = dialog.value?.querySelector('input')
|
||||||
@@ -41,6 +80,29 @@ const show = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
defineExpose({ show, close })
|
defineExpose({ show, close })
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => store.dialog === props.name,
|
||||||
|
isOpen => {
|
||||||
|
if (isOpen && !backdropHeld) {
|
||||||
|
ensureGlobalBackdropStyles()
|
||||||
|
holdGlobalBackdrop()
|
||||||
|
backdropHeld = true
|
||||||
|
} else if (!isOpen && backdropHeld) {
|
||||||
|
releaseGlobalBackdrop()
|
||||||
|
backdropHeld = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (backdropHeld) {
|
||||||
|
releaseGlobalBackdrop()
|
||||||
|
backdropHeld = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (overlay.value) {
|
if (overlay.value) {
|
||||||
overlay.value.focus()
|
overlay.value.focus()
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
paskia?: boolean
|
paskia?: boolean
|
||||||
office_previews?: boolean
|
office_previews?: boolean
|
||||||
},
|
},
|
||||||
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens',
|
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens' | 'about',
|
||||||
uprogress: {} as any,
|
uprogress: {} as any,
|
||||||
dprogress: {} as any,
|
dprogress: {} as any,
|
||||||
prefs: {
|
prefs: {
|
||||||
@@ -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