Compare commits

...
4 Commits
9 changed files with 174 additions and 62 deletions
+58 -14
View File
@@ -29,7 +29,13 @@
@after-enter="store.transitionDirection = 'none'"
>
<div :key="routeViewKey" class="explorer-content">
<RouterView :path="path.pathList" :query="path.query" />
<KeepAlive>
<component
:is="routeViewComponent"
:key="routeViewKey"
v-bind="routeViewProps"
/>
</KeepAlive>
</div>
</Transition>
</main>
@@ -43,16 +49,18 @@
<script setup lang="ts">
import type HeaderMain from '@/components/HeaderMain.vue'
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main'
import type { ComputedRef } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterView } from 'vue-router'
import Router from '@/router/index'
import { computed } from 'vue'
import AboutModal from './components/AboutModal.vue'
import AccessDeniedModal from './components/AccessDeniedModal.vue'
import ExplorerView from './views/ExplorerView.vue'
import SelectionToolbar from './components/SelectionToolbar.vue'
import TextEditorView from './views/TextEditorView.vue'
import type SettingsModalVue from './components/SettingsModal.vue'
import UserManagementModal from './components/UserManagementModal.vue'
import UserTokensModal from './components/UserTokensModal.vue'
@@ -60,6 +68,7 @@ import type { SortOrder } from './utils/docsort'
interface Path {
path: string
canonicalPath: string
isEditorPath: boolean
pathList: string[]
breadcrumbPathList: string[]
@@ -67,26 +76,42 @@ interface Path {
query: string
}
const store = useMainStore()
const getDocByPath = (fullPath: string) =>
getDocuments().find(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === fullPath)
const path: ComputedRef<Path> = computed(() => {
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
const routePathList = (p[0] ?? '').split('/').filter(value => value !== '')
const rawPath = p[0] ?? ''
const routePathList = rawPath.split('/').filter(value => value !== '')
const query = p.slice(1).join('//')
const isEditorPath = routePathList[0] === 'edit'
const pathList = isEditorPath ? routePathList.slice(1, -1) : routePathList
const breadcrumbPathList = isEditorPath
? routePathList.slice(1)
: routePathList
const fullPath = routePathList.join('/')
// Access docVersion to make route mode reactive to tree updates
void store.docVersion
const doc = fullPath ? getDocByPath(fullPath) : null
const isEditorPath = !!(doc && !doc.dir && doc.text)
const canonicalBase = !fullPath
? '/'
: doc?.dir
? `/${fullPath}/`
: `/${fullPath}`
const canonicalPath = query
? rawPath // keep search URL shape untouched
: canonicalBase
const pathList = isEditorPath ? routePathList.slice(0, -1) : routePathList
const breadcrumbPathList = routePathList
const breadcrumbLinks = isEditorPath
? [
'/',
...routePathList
.slice(1, -1)
.map((_, index) => `/${routePathList.slice(1, index + 2).join('/')}/`),
`/${routePathList.join('/')}`
.slice(0, -1)
.map((_, index) => `/${routePathList.slice(0, index + 1).join('/')}/`),
`/${fullPath}`
]
: undefined
return {
path: p[0] ?? '',
path: rawPath,
canonicalPath,
isEditorPath,
pathList,
breadcrumbPathList,
@@ -99,10 +124,29 @@ const routeTransitionName = computed(() => {
if (store.transitionDirection === 'backward') return 'slide-backward'
return ''
})
const routeViewComponent = computed(() =>
path.value.isEditorPath ? TextEditorView : ExplorerView
)
const routeViewKey = computed(() => {
const route = Router.currentRoute.value
return route.name === 'editor' ? route.path : String(route.name ?? route.path)
return path.value.isEditorPath
? `editor:${path.value.path}`
: 'explorer'
})
const routeViewProps = computed(() =>
path.value.isEditorPath
? {}
: { path: path.value.pathList, query: path.value.query }
)
watch(
() => path.value.canonicalPath,
canonical => {
const current = decodeURIComponent(Router.currentRoute.value.path)
if (canonical && current !== canonical) {
Router.replace(canonical.replaceAll('?', '%3F').replaceAll('#', '%23'))
}
},
{ immediate: true }
)
watch(
() => path.value.path,
() => {
+2
View File
@@ -62,10 +62,12 @@
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
height: 100%;
}
.explorer-content {
grid-area: 1 / 1;
height: 100%;
}
.slide-forward-enter-active,
+1 -1
View File
@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28">
<path fill-rule="evenodd" d="M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zM22.75 18.55c0 .2625-.175.4375-.4375.4375h-4.55v4.55c0 .2625-.175.4375-.4375.4375h-2.45c-.2625 0-.4375-.175-.4375-.4375v-4.55h-4.55c-.2625 0-.4375-.175-.4375-.4375V16.1c0-.2625.175-.4375.4375-.4375h4.55v-4.55c0-.2625.175-.4375.4375-.4375h2.45c.2625 0 .4375.175.4375.4375v4.55h4.55c.2625 0 .4375.175.4375.4375v2.45z" />
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 451 B

After

Width:  |  Height:  |  Size: 452 B

+1 -1
View File
@@ -363,7 +363,7 @@ onUnmounted(() => {
clearInterval(modifiedTimer)
})
const editRoute = (path: string) =>
'/edit/' +
'/' +
path
.split('/')
.map(part => encodeURIComponent(part))
+28 -8
View File
@@ -23,6 +23,8 @@ import type { SortOrder } from '@/utils/docsort'
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
import ContextMenu from '@imengyu/vue3-context-menu'
import {
onActivated,
onDeactivated,
computed,
nextTick,
onMounted,
@@ -170,6 +172,7 @@ const onImgLoad = (e: Event) => {
}
const updateColumns = () => {
if (!gallery.value) return
if (gallery.value.getBoundingClientRect().width <= 0) return
const style = getComputedStyle(gallery.value)
const templates = style.gridTemplateColumns
.split(' ')
@@ -412,6 +415,19 @@ watchEffect(() => {
}
})
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) {
@@ -419,22 +435,26 @@ onMounted(() => {
}
updateColumns()
seedFromDocs()
if (gallery.value) {
resizeObserver = new ResizeObserver(updateColumns)
resizeObserver.observe(gallery.value)
gallery.value.addEventListener('load', onImgLoad, { capture: true })
}
attachGalleryObservers()
})
onActivated(() => {
nextTick(() => {
updateColumns()
attachGalleryObservers()
})
})
onDeactivated(() => {
detachGalleryObservers()
})
onUnmounted(() => {
keyboardFollowScroll.cancel()
resizeObserver?.disconnect()
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
detachGalleryObservers()
})
// Re-seed aspect ratios whenever docs update (e.g., ar patch from server)
watch(() => props.documents, seedFromDocs)
const editRoute = (path: string) =>
'/edit/' +
'/' +
path
.split('/')
.map(part => encodeURIComponent(part))
+1 -1
View File
@@ -95,7 +95,7 @@ export class Doc {
get editurl(): string {
if (!this.text) return ''
const p = this.loc ? `${this.loc}/${this.name}` : this.name
return '/#/edit/' + p.replaceAll('?', '%3F').replaceAll('#', '%23')
return '/#/' + p.replaceAll('?', '%3F').replaceAll('#', '%23')
}
get complete(): boolean {
return !this.ghost && (this.dir || this.size <= this.allocated)
-6
View File
@@ -1,6 +1,5 @@
import { useMainStore } from '@/stores/main'
import ExplorerView from '@/views/ExplorerView.vue'
import TextEditorView from '@/views/TextEditorView.vue'
import { createRouter, createWebHashHistory } from 'vue-router'
function getPathDepth(path: string): number {
@@ -11,11 +10,6 @@ function getPathDepth(path: string): number {
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/edit/:pathMatch(.*)*',
name: 'editor',
component: TextEditorView
},
{
path: '/:pathMatch(.*)*',
name: 'explorer',
+39 -13
View File
@@ -2,24 +2,20 @@
<div class="transition-wrapper">
<Transition
:name="transitionName"
@after-enter="store.transitionDirection = 'none'"
@after-enter="onAfterEnter"
>
<div :key="folderPath" class="explorer-content">
<Gallery
v-if="store.prefs.gallery"
<KeepAlive>
<component
:is="store.prefs.gallery ? Gallery : FileExplorer"
:key="cacheKey"
ref="fileExplorer"
class="explorer-content"
:path="props.path"
:documents="documents"
/>
<FileExplorer
v-else
ref="fileExplorer"
:path="props.path"
:documents="documents"
/>
<EmptyFolder :documents="documents" :path="props.path" />
</div>
</KeepAlive>
</Transition>
<EmptyFolder :documents="documents" :path="props.path" />
</div>
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
</template>
@@ -31,7 +27,7 @@ import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main'
import { collator } from '@/utils'
import { sorted, sortedGrouped } from '@/utils/docsort'
import { computed, ref, watch, watchEffect } from 'vue'
import { computed, nextTick, ref, watch, watchEffect } from 'vue'
const store = useMainStore()
const fileExplorer = ref()
@@ -42,6 +38,7 @@ const props = defineProps<{
// Folder path for component keys - only recreate component when folder changes, not search
const folderPath = computed(() => props.path.join('/'))
const cacheKey = computed(() => `${store.prefs.gallery ? 'gallery' : 'list'}:${folderPath.value}`)
const transitionName = computed(() => {
if (store.transitionDirection === 'forward') return 'slide-forward'
@@ -49,6 +46,22 @@ const transitionName = computed(() => {
return ''
})
const folderScrollTop = new Map<string, number>()
const scrollKey = (path: string) => path || '/'
const getMainScroller = () => document.querySelector('main') as HTMLElement | null
const restoreScroll = (path: string) => {
const scroller = getMainScroller()
if (!scroller) return
const top = folderScrollTop.get(scrollKey(path)) ?? 0
scroller.scrollTop = top
}
const onAfterEnter = () => {
store.transitionDirection = 'none'
restoreScroll(folderPath.value)
}
// Handle route-based search changes (back/forward navigation, direct URL)
// Skip if store.query already matches (means we triggered this via typing)
watch(
@@ -101,6 +114,19 @@ watchEffect(() => {
store.fileExplorer = fileExplorer.value
})
watch(
folderPath,
async (path, oldPath) => {
const scroller = getMainScroller()
if (scroller && oldPath !== undefined) {
folderScrollTop.set(scrollKey(oldPath), scroller.scrollTop)
}
await nextTick()
requestAnimationFrame(() => restoreScroll(path))
},
{ immediate: true }
)
// Only auto-switch gallery mode when entering a new folder or on initial file list load
watch(
[() => props.path.join('/'), () => store.documentCount],
+44 -18
View File
@@ -18,15 +18,26 @@ import { EditorView, keymap } from '@codemirror/view'
import { basicSetup } from 'codemirror'
import { apiFetch } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { onBeforeRouteLeave, useRoute } from 'vue-router'
import {
computed,
nextTick,
onActivated,
onDeactivated,
onMounted,
onUnmounted,
ref
} from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const store = useMainStore()
const MAX_SIZE = 1024 * 1024 // 1 MiB
const filePath = computed(() => decodeURIComponent(route.path.slice(6))) // strip /edit/
const filePath = computed(() => {
const raw = decodeURIComponent(route.path).split('//')[0] ?? ''
return raw.replace(/^\//, '').replace(/\/$/, '')
})
const filename = computed(() => filePath.value.split('/').pop() || '')
const filesUrl = computed(() => {
@@ -50,21 +61,32 @@ const languageCompartment = new Compartment()
const dirty = computed(() => content.value !== original.value)
onBeforeRouteLeave((_to, _from, next) => {
if (!dirty.value) {
next()
return
}
const discard = window.confirm('You have unsaved changes. Discard them?')
next(discard)
})
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirty.value) return
event.preventDefault()
event.returnValue = ''
}
let beforeUnloadActive = false
const activateEditorBindings = () => {
store.editorSave = save
if (!beforeUnloadActive) {
window.addEventListener('beforeunload', beforeUnload)
beforeUnloadActive = true
}
}
const deactivateEditorBindings = () => {
if (store.editorSave === save) {
store.editorSave = null
}
if (beforeUnloadActive) {
window.removeEventListener('beforeunload', beforeUnload)
beforeUnloadActive = false
}
}
const detectLanguage = async () => {
const language = LanguageDescription.matchFilename(languages, filename.value)
if (!language) return []
@@ -129,8 +151,7 @@ const save = async () => {
}
onMounted(async () => {
store.editorSave = save
window.addEventListener('beforeunload', beforeUnload)
activateEditorBindings()
loading.value = true
error.value = ''
try {
@@ -158,13 +179,18 @@ onMounted(async () => {
}
})
onActivated(() => {
activateEditorBindings()
})
onDeactivated(() => {
deactivateEditorBindings()
})
onUnmounted(() => {
if (store.editorSave === save) {
store.editorSave = null
}
deactivateEditorBindings()
editorView?.destroy()
editorView = null
window.removeEventListener('beforeunload', beforeUnload)
})
</script>