|
<script lang="ts"> |
|
import EmptyPane from '@front/features/layout/EmptyPane.vue' |
|
|
|
import { computed, defineComponent, ref, watch } from 'vue' |
|
import { getStorage, setStorage } from '@vue-devtools/shared-utils' |
|
import { useRoute, useRouter } from 'vue-router' |
|
import { onKeyDown } from '@front/util/keyboard' |
|
import TimelineEventListItem from './TimelineEventListItem.vue' |
|
import { selectEvent, useInspectedEvent, useLayers, useSelectedEvent } from './composable' |
|
|
|
const itemHeight = 34 |
|
|
|
const STORAGE_TAB_ID = 'timeline.event-list.tab-id' |
|
|
|
export default defineComponent({ |
|
components: { |
|
TimelineEventListItem, |
|
EmptyPane, |
|
}, |
|
|
|
setup() { |
|
const route = useRoute() |
|
const router = useRouter() |
|
|
|
const { |
|
selectedLayer, |
|
} = useLayers() |
|
|
|
const { |
|
selectedEvent, |
|
selectedGroupEvents, |
|
} = useSelectedEvent() |
|
|
|
const { |
|
inspectedEvent, |
|
} = useInspectedEvent() |
|
|
|
|
|
|
|
const tabId = computed({ |
|
get: () => route.query.tabId, |
|
set: (value) => { |
|
setStorage(STORAGE_TAB_ID, value) |
|
router.push({ |
|
query: { |
|
...route.query, |
|
tabId: value, |
|
}, |
|
}) |
|
}, |
|
}) |
|
|
|
if (!route.query.tabId) { |
|
tabId.value = getStorage(STORAGE_TAB_ID, 'all') |
|
} |
|
|
|
watch(selectedEvent, (value) => { |
|
if (value && !value.group && tabId.value === 'group') { |
|
tabId.value = 'all' |
|
} |
|
}) |
|
|
|
const displayedEvents = computed(() => { |
|
switch (tabId.value) { |
|
case 'group': |
|
return selectedGroupEvents.value ?? [] |
|
case 'all': |
|
default: |
|
return selectedLayer.value?.events ?? [] |
|
} |
|
}) |
|
|
|
|
|
|
|
const filter = ref('') |
|
|
|
const filteredEvents = computed(() => { |
|
const rawFilter = filter.value.trim() |
|
if (rawFilter) { |
|
const reg = new RegExp(rawFilter, 'i') |
|
return displayedEvents.value.filter(event => |
|
(event.title && reg.test(event.title)) |
|
|| (event.subtitle && reg.test(event.subtitle)), |
|
) |
|
} |
|
else { |
|
return displayedEvents.value |
|
} |
|
}) |
|
|
|
|
|
|
|
const scroller = ref() |
|
|
|
const isAtScrollBottom = ref(false) |
|
|
|
function onScroll() { |
|
const scrollerEl = scroller.value.$el |
|
isAtScrollBottom.value = scrollerEl.scrollTop + scrollerEl.clientHeight >= scrollerEl.scrollHeight - 400 |
|
} |
|
|
|
watch(scroller, (value) => { |
|
if (value) { |
|
onScroll() |
|
} |
|
}, { immediate: true }) |
|
|
|
watch(tabId, () => { |
|
checkScrollToInspectedEvent() |
|
}, { immediate: true }) |
|
|
|
function scrollToInspectedEvent() { |
|
if (!scroller.value) { |
|
return |
|
} |
|
|
|
const scrollerEl = scroller.value.$el |
|
|
|
const index = filteredEvents.value.indexOf(inspectedEvent.value) |
|
if (index !== -1) { |
|
|
|
isAtScrollBottom.value = false |
|
scrollerEl.scrollTop = itemHeight * (index + 0.5) - (scrollerEl.clientHeight) / 2 |
|
} |
|
} |
|
|
|
watch(inspectedEvent, () => { |
|
checkScrollToInspectedEvent() |
|
}, { immediate: true }) |
|
|
|
function checkScrollToInspectedEvent() { |
|
requestAnimationFrame(() => { |
|
if (!scroller.value) { |
|
return |
|
} |
|
|
|
const scrollerEl = scroller.value.$el |
|
|
|
const index = filteredEvents.value.indexOf(inspectedEvent.value) |
|
const minPosition = itemHeight * index |
|
const maxPosition = minPosition + itemHeight |
|
|
|
if (scrollerEl.scrollTop > minPosition || scrollerEl.scrollTop + scrollerEl.clientHeight < maxPosition) { |
|
scrollToInspectedEvent() |
|
} |
|
}) |
|
} |
|
|
|
|
|
|
|
function scrollToBottom() { |
|
requestAnimationFrame(() => { |
|
if (!scroller.value) { |
|
return |
|
} |
|
const scrollerEl = scroller.value.$el |
|
scrollerEl.scrollTop = scrollerEl.scrollHeight |
|
}) |
|
} |
|
|
|
|
|
watch(() => filteredEvents.value.length, () => { |
|
if (isAtScrollBottom.value) { |
|
scrollToBottom() |
|
} |
|
}, { immediate: true }) |
|
|
|
|
|
|
|
function inspectEvent(event) { |
|
inspectedEvent.value = event |
|
} |
|
|
|
|
|
|
|
onKeyDown((event) => { |
|
const index = filteredEvents.value.indexOf(inspectedEvent.value) |
|
if (event.key === 'ArrowDown') { |
|
if (index < filteredEvents.value.length - 1) { |
|
inspectEvent(filteredEvents.value[index + 1]) |
|
} |
|
} |
|
else if (event.key === 'ArrowUp') { |
|
if (index > 0) { |
|
inspectEvent(filteredEvents.value[index - 1]) |
|
} |
|
} |
|
else if (event.key === 'Enter' || event.key === ' ') { |
|
if (inspectedEvent.value) { |
|
selectEvent(inspectedEvent.value) |
|
} |
|
} |
|
}) |
|
|
|
return { |
|
selectedEvent, |
|
selectedLayer, |
|
tabId, |
|
scroller, |
|
filter, |
|
filteredEvents, |
|
itemHeight, |
|
isAtScrollBottom, |
|
inspectEvent, |
|
selectEvent, |
|
onScroll, |
|
scrollToBottom, |
|
} |
|
}, |
|
}) |
|
</script> |
|
|
|
<template> |
|
<div |
|
v-if="selectedLayer && (filteredEvents.length || filter.length)" |
|
class="h-full flex flex-col relative" |
|
> |
|
<div class="flex-none flex flex-col items-stretch border-gray-200 dark:border-gray-700 border-b"> |
|
<VueGroup |
|
v-if="selectedEvent && selectedEvent.group" |
|
v-model="tabId" |
|
indicator |
|
class="accent extend border-gray-200 dark:border-gray-700 border-b" |
|
> |
|
<VueGroupButton |
|
value="all" |
|
label="All" |
|
class="flat" |
|
/> |
|
<VueGroupButton |
|
value="group" |
|
label="Group" |
|
class="flat" |
|
/> |
|
</VueGroup> |
|
|
|
<VueInput |
|
v-model="filter" |
|
icon-left="search" |
|
:placeholder="`Filter ${selectedLayer.label}`" |
|
class="search flat h-[31px]" |
|
/> |
|
</div> |
|
|
|
<RecycleScroller |
|
:key="tabId" |
|
ref="scroller" |
|
:items="filteredEvents" |
|
:item-size="itemHeight" |
|
class="flex-1" |
|
@scroll.passive="onScroll()" |
|
> |
|
<template #default="{ item: event }"> |
|
<TimelineEventListItem |
|
:event="event" |
|
:selected="selectedEvent === event" |
|
@inspect="inspectEvent(event)" |
|
@select="selectEvent(event)" |
|
/> |
|
</template> |
|
</RecycleScroller> |
|
|
|
<VueButton |
|
v-if="!isAtScrollBottom" |
|
v-tooltip="'Scroll to bottom'" |
|
icon-left="keyboard_arrow_down" |
|
class="icon-button absolute bottom-1 right-4 rounded-full shadow-md" |
|
@click="scrollToBottom()" |
|
/> |
|
</div> |
|
|
|
<EmptyPane |
|
v-else |
|
:icon="!selectedLayer ? 'layers' : 'inbox'" |
|
> |
|
<template v-if="!selectedLayer"> |
|
Select a layer to get started |
|
</template> |
|
<template v-else> |
|
No events |
|
</template> |
|
</EmptyPane> |
|
</template> |
|
|