Browse Source

feat: smooth scroll for command palette

nc-fix/cmdk-ws-2
mertmit 2 months ago
parent
commit
d8f4bf2982
  1. 264
      packages/nc-gui/components/cmd-k/index.vue

264
packages/nc-gui/components/cmd-k/index.vue

@ -1,5 +1,4 @@
<script lang="ts" setup>
import { UseVirtualList } from '@vueuse/components'
import { useMagicKeys, whenever } from '@vueuse/core'
import { commandScore } from './command-score'
import type { CommandPaletteType } from '~/lib/types'
@ -47,10 +46,16 @@ const { user } = useGlobal()
const selected = ref<string>()
const virtualList = ref()
const cmdkActionsRef = ref<HTMLElement>()
const cmdkActionSelectedRef = ref<HTMLElement>()
const ACTION_HEIGHT = 48
const WRAPPER_HEIGHT = 300
const SCROLL_MARGIN = ACTION_HEIGHT / 2
const { cmdPlaceholder, loadScope, cmdLoading } = useCommandPalette()
const formattedData: ComputedRef<(CmdAction & { weight: number })[]> = computed(() => {
@ -156,53 +161,92 @@ const searchedActionList = computed(() => {
.sort((a, b) => b.section?.toLowerCase().localeCompare(a.section?.toLowerCase() as string) || 0)
})
const visibleSections = computed(() => {
const sections: string[] = []
searchedActionList.value.forEach((el) => {
if (el.section && !sections.includes(el.section)) {
sections.push(el.section)
}
})
return sections
})
const actionListNormalized = computed(() => {
const rt: (CmdAction | { sectionTitle: string })[] = []
const sections = new Set(searchedActionList.value.filter((el) => el.section).map((el) => el.section))
sections.forEach((el) => {
visibleSections.value.forEach((el) => {
rt.push({ sectionTitle: el || 'default' })
rt.push(...searchedActionList.value.filter((el2) => el2.section === el))
})
return rt
})
const { list, containerProps, wrapperProps } = useVirtualList(actionListNormalized, {
itemHeight: ACTION_HEIGHT,
})
const keys = useMagicKeys()
const shiftModifier = keys.shift
const setAction = (action: string) => {
const oldActionIndex = searchedActionList.value.findIndex((el) => el.id === selected.value)
selected.value = action
nextTick(() => {
const actionIndex = searchedActionList.value.findIndex((el) => el.id === action)
if (actionIndex === -1) return
if (actionIndex === 0) {
containerProps.ref.value?.scrollTo({ top: 0, behavior: 'smooth' })
return
} else if (actionIndex === searchedActionList.value.length - 1) {
containerProps.ref.value?.scrollTo({
top: actionIndex * ACTION_HEIGHT,
behavior: 'smooth',
})
return
}
// check if selected action rendered
const actionEl = document.querySelector('.cmdk-action.selected')
if (!actionEl) {
virtualList.value?.scrollTo(actionIndex)
const actionEl = Array.isArray(cmdkActionSelectedRef.value) ? cmdkActionSelectedRef.value[0] : cmdkActionSelectedRef.value
if (!actionEl || !actionEl.classList?.contains('selected')) {
// if above the old selected action
if (actionIndex < oldActionIndex) {
containerProps.ref.value?.scrollTo({ top: (actionIndex + 1) * ACTION_HEIGHT - SCROLL_MARGIN, behavior: 'smooth' })
} else {
containerProps.ref.value?.scrollTo({
top: (actionIndex + 2) * ACTION_HEIGHT - WRAPPER_HEIGHT + SCROLL_MARGIN,
behavior: 'smooth',
})
}
return
}
// count sections before the selected action
const sectionBefore = visibleSections.value.findIndex((el) => el === searchedActionList.value[actionIndex].section) + 1
// check if selected action is visible in the list
const actionRect = actionEl?.getBoundingClientRect()
if (actionRect) {
const listRect = document.querySelector('.cmdk-actions')?.getBoundingClientRect()
if (listRect) {
if (actionRect.top < listRect.top) {
virtualList.value?.scrollTo(actionIndex)
} else if (actionRect.bottom > listRect.bottom) {
virtualList.value?.scrollTo(actionIndex)
const listRect = cmdkActionsRef.value?.getBoundingClientRect()
if (actionRect && listRect) {
if (actionRect.top < listRect.top || actionRect.bottom > listRect.bottom) {
// if above the old selected action
if (actionIndex < oldActionIndex) {
containerProps.ref.value?.scrollTo({
top: (actionIndex + sectionBefore) * ACTION_HEIGHT - SCROLL_MARGIN,
behavior: 'smooth',
})
} else {
containerProps.ref.value?.scrollTo({
top: (actionIndex + 1 + sectionBefore) * ACTION_HEIGHT - WRAPPER_HEIGHT + SCROLL_MARGIN,
behavior: 'smooth',
})
}
}
}
})
}
const selectFirstAction = () => {
if (searchedActionList.value.length > 0) {
setAction(searchedActionList.value[0].id)
} else {
selected.value = undefined
}
}
const setScope = (scope: string) => {
activeScope.value = scope
@ -210,7 +254,6 @@ const setScope = (scope: string) => {
nextTick(() => {
cmdInputEl.value?.focus()
selectFirstAction()
})
}
@ -255,7 +298,6 @@ const updateDebouncedInput = useDebounceFn(() => {
nextTick(() => {
cmdInputEl.value?.focus()
selectFirstAction()
})
}, 100)
@ -338,6 +380,12 @@ onClickOutside(modalEl, () => {
if (vOpen.value) hide()
})
watch(searchedActionList, () => {
if (searchedActionList.value.length > 0) {
setAction(searchedActionList.value[0].id)
}
})
defineExpose({
open: show,
close: hide,
@ -408,18 +456,11 @@ defineExpose({
<span class="text-gray-700 text-sm pl-1 font-medium">/</span>
</div>
<input
ref="cmdInputEl"
v-model="cmdInput"
class="cmdk-input"
type="text"
:placeholder="cmdPlaceholder"
@input="selectFirstAction"
/>
<input ref="cmdInputEl" v-model="cmdInput" class="cmdk-input" type="text" :placeholder="cmdPlaceholder" />
</div>
</div>
<div class="cmdk-body">
<div class="cmdk-actions nc-scrollbar-md">
<div ref="cmdkActionsRef" class="cmdk-actions nc-scrollbar-md">
<div v-if="searchedActionList.length === 0 && cmdLoading" class="w-full h-[250px] flex justify-center items-center">
<GeneralLoader :size="30" />
</div>
@ -430,91 +471,94 @@ defineExpose({
</div>
<template v-else>
<div class="cmdk-action-list border-t-1 border-gray-200">
<UseVirtualList
ref="virtualList"
:list="actionListNormalized"
height="300px"
:options="{ itemHeight: ACTION_HEIGHT }"
>
<template #default="{ data: act }">
<template v-if="act.sectionTitle">
<div
class="cmdk-action-section-header capitalize"
:style="{
height: `${ACTION_HEIGHT}px`,
}"
>
{{ act.sectionTitle }}
</div>
</template>
<template v-else>
<div
:key="act.id"
v-e="['a:cmdk:action']"
class="cmdk-action group flex items-center"
:style="{
height: `${ACTION_HEIGHT}px`,
}"
:class="{ selected: selected === act.id }"
@mouseenter="setAction(act.id)"
@click="fireAction(act)"
>
<div class="cmdk-action-content w-full">
<GeneralWorkspaceIcon
v-if="act.icon && act.id.startsWith('ws')"
:workspace="{
id: act.id.split('-')[2],
meta: {
color: act?.iconColor,
},
}"
class="mr-2"
size="small"
/>
<template v-else-if="act.section === 'Bases' || act.icon === 'project'">
<GeneralBaseIconColorPicker :key="act.iconColor" :model-value="act.iconColor" type="database" readonly>
</GeneralBaseIconColorPicker>
</template>
<template v-else>
<component
:is="(iconMap as any)[act.icon]"
v-if="act.icon && typeof act.icon === 'string' && (iconMap as any)[act.icon]"
:class="{
'!text-blue-500': act.icon === 'grid',
'!text-purple-500': act.icon === 'form',
'!text-[#FF9052]': act.icon === 'kanban',
'!text-pink-500': act.icon === 'gallery',
'!text-maroon-500 w-4 h-4': act.icon === 'calendar',
<div v-bind="containerProps" :style="`height: ${WRAPPER_HEIGHT}px`">
<div v-bind="wrapperProps">
<div v-for="item in list" :key="item.index" :style="`height: ${ACTION_HEIGHT}px`">
<template v-if="'sectionTitle' in item.data">
<div
class="cmdk-action-section-header capitalize"
:style="{
height: `${ACTION_HEIGHT}px`,
}"
>
{{ item.data.sectionTitle }}
</div>
</template>
<template v-else>
<div
:ref="item.data.id === selected ? 'cmdkActionSelectedRef' : undefined"
:key="`${item.data.id}-${item.data.id === selected}`"
v-e="['a:cmdk:action']"
class="cmdk-action group flex items-center"
:style="{
height: `${ACTION_HEIGHT}px`,
}"
:class="{ selected: selected === item.data.id }"
@mouseenter="setAction(item.data.id)"
@click="fireAction(item.data)"
>
<div class="cmdk-action-content w-full">
<GeneralWorkspaceIcon
v-if="item.data.icon && item.data.id.startsWith('ws')"
:workspace="{
id: item.data.id.split('-')[2],
meta: {
color: item.data?.iconColor,
},
}"
class="cmdk-action-icon"
class="mr-2"
size="small"
/>
<div v-else-if="act.icon" class="cmdk-action-icon max-w-4 flex items-center justify-center">
<LazyGeneralEmojiPicker class="!text-sm !h-4 !w-4" size="small" :emoji="act.icon" readonly />
</div>
</template>
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg">
<template #title>
{{ act.title }}
<template v-else-if="item.data.section === 'Bases' || item.data.icon === 'project'">
<GeneralBaseIconColorPicker
:key="item.data.iconColor"
:model-value="item.data.iconColor"
type="database"
readonly
>
</GeneralBaseIconColorPicker>
</template>
<template v-else>
<component
:is="(iconMap as any)[item.data.icon]"
v-if="item.data.icon && typeof item.data.icon === 'string' && (iconMap as any)[item.data.icon]"
:class="{
'!text-blue-500': item.data.icon === 'grid',
'!text-purple-500': item.data.icon === 'form',
'!text-[#FF9052]': item.data.icon === 'kanban',
'!text-pink-500': item.data.icon === 'gallery',
'!text-maroon-500 w-4 h-4': item.data.icon === 'calendar',
}"
class="cmdk-action-icon"
/>
<div v-else-if="item.data.icon" class="cmdk-action-icon max-w-4 flex items-center justify-center">
<LazyGeneralEmojiPicker class="!text-sm !h-4 !w-4" size="small" :emoji="item.data.icon" readonly />
</div>
</template>
<span class="truncate capitalize mr-4 py-0.5">
{{ act.title }}
</span>
</a-tooltip>
<div
class="bg-gray-200 text-gray-600 cmdk-keyboard hidden text-xs gap-2 p-0.5 items-center justify-center rounded-md ml-auto pl-2"
>
Enter
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg">
<template #title>
{{ item.data.title }}
</template>
<span class="truncate capitalize mr-4 py-0.5">
{{ item.data.title }}
</span>
</a-tooltip>
<div
class="bg-white border-1 items-center flex justify-center border-gray-300 text-gray-700 rounded h-5 w-5 px-0.25"
class="bg-gray-200 text-gray-600 cmdk-keyboard hidden text-xs gap-2 p-0.5 items-center justify-center rounded-md ml-auto pl-2"
>
Enter
<div
class="bg-white border-1 items-center flex justify-center border-gray-300 text-gray-700 rounded h-5 w-5 px-0.25"
>
</div>
</div>
</div>
</div>
</div>
</template>
</template>
</UseVirtualList>
</template>
</div>
</div>
</div>
</div>
</template>
</div>

Loading…
Cancel
Save