Browse Source

feat(nc-gui): added keyboard shortcuts setup for oss

pull/7490/head
Ramesh Mane 5 months ago
parent
commit
ed3d134ac9
  1. 38
      packages/nc-gui/app.vue
  2. 2
      packages/nc-gui/assets/css/typesense-docsearch.css
  3. 34
      packages/nc-gui/components/cmd-j/index.vue
  4. 162
      packages/nc-gui/components/cmd-k/command-score.ts
  5. 671
      packages/nc-gui/components/cmd-k/index.vue
  6. 329
      packages/nc-gui/components/cmd-l/index.vue
  7. 27
      packages/nc-gui/components/dashboard/Sidebar/TopSection/Header.vue
  8. 77
      packages/nc-gui/composables/useCommandPalette/commands.ts
  9. 170
      packages/nc-gui/composables/useCommandPalette/index.ts
  10. 6
      packages/nc-gui/nuxt.config.ts
  11. 7
      packages/nc-gui/public/js/typesense-docsearch.js

38
packages/nc-gui/app.vue

@ -1,14 +1,20 @@
<script setup lang="ts">
import { applyNonSelectable, computed, useRouter, useTheme } from '#imports'
import { applyNonSelectable, computed, useRouter, useTheme, useCommandPalette } from '#imports'
const router = useRouter()
const route = router.currentRoute
const cmdK = ref(false)
const cmdL = ref(false)
const disableBaseLayout = computed(() => route.value.path.startsWith('/nc/view') || route.value.path.startsWith('/nc/form'))
useTheme()
const { commandPalette, cmdData, cmdPlaceholder, activeScope, loadTemporaryScope } = useCommandPalette()
applyNonSelectable()
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
@ -19,6 +25,16 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
if (!['input', 'textarea'].includes((e.target as any).nodeName.toLowerCase())) {
e.preventDefault()
}
break
case 'k':
e.preventDefault()
break
case 'l':
e.preventDefault()
break
case 'j':
e.preventDefault()
break
}
}
})
@ -47,6 +63,12 @@ if (typeof window !== 'undefined') {
// @ts-expect-error using arbitrary window key
window.__ncvue = true
}
function onScope(scope: string) {
if (scope === 'root') {
loadTemporaryScope({ scope: 'root', data: {} })
}
}
</script>
<template>
@ -55,4 +77,18 @@ if (typeof window !== 'undefined') {
<NuxtPage :key="key" :transition="false" />
</NuxtLayout>
</a-config-provider>
<!-- Command Menu -->
<CmdK
ref="commandPalette"
v-model:open="cmdK"
:scope="activeScope.scope"
:data="cmdData"
:placeholder="cmdPlaceholder"
:load-temporary-scope="loadTemporaryScope"
@scope="onScope"
/>
<!-- Recent Views. Cycles through recently visited Views -->
<CmdL v-model:open="cmdL" />
<!-- Documentation. Integrated NocoDB Docs directlt inside the Product -->
<CmdJ />
</template>

2
packages/nc-gui/assets/css/typesense-docsearch.css

File diff suppressed because one or more lines are too long

34
packages/nc-gui/components/cmd-j/index.vue

@ -0,0 +1,34 @@
<script setup lang="ts">
const modalEl = ref<HTMLElement | null>(null)
const { user } = useGlobal()
watch(user, () => {
window.doc_enabled = !!user.value
})
onMounted(() => {
docsearch({
container: '#searchbar',
typesenseCollectionName: 'nocodb-oss-docs-index',
typesenseServerConfig: {
nodes: [
{
host: 'rqf5uvajyeczwt3xp-1.a1.typesense.net',
port: 443,
protocol: 'https',
},
],
apiKey: 'lNKDTZdJrE76Sg8WEyeN9mXT29l1xq7Q',
},
typesenseSearchParameters: {
// Optional.
},
})
})
</script>
<template>
<div id="searchbar" :ref="modalEl" class="hidden"></div>
</template>
<style></style>

162
packages/nc-gui/components/cmd-k/command-score.ts

@ -0,0 +1,162 @@
// this is derived from https://github.com/pacocoursey/cmdk
// The scores are arranged so that a continuous match of characters will
// result in a total score of 1.
//
// The best case, this character is a match, and either this is the start
// of the string, or the previous character was also a match.
const SCORE_CONTINUE_MATCH = 1.9
// A new match at the start of a word scores better than a new match
// elsewhere as it's more likely that the user will type the starts
// of fragments.
// NOTE: We score word jumps between spaces slightly higher than slashes, brackets
// hyphens, etc.
const SCORE_SPACE_WORD_JUMP = 1.0
const SCORE_NON_SPACE_WORD_JUMP = 0.8
// Any other match isn't ideal, but we include it for completeness.
const SCORE_CHARACTER_JUMP = 0.2
// If the user transposed two letters, it should be significantly penalized.
//
// i.e. "ouch" is more likely than "curtain" when "uc" is typed.
const SCORE_TRANSPOSITION = 0.3
// The goodness of a match should decay slightly with each missing
// character.
//
// i.e. "bad" is more likely than "bard" when "bd" is typed.
//
// This will not change the order of suggestions based on SCORE_* until
// 100 characters are inserted between matches.
const PENALTY_SKIPPED = 0.999
// The goodness of an exact-case match should be higher than a
// case-insensitive match by a small amount.
//
// i.e. "HTML" is more likely than "haml" when "HM" is typed.
//
// This will not change the order of suggestions based on SCORE_* until
// 1000 characters are inserted between matches.
const PENALTY_CASE_MISMATCH = 0.999999
// Match higher for letters closer to the beginning of the word
// const PENALTY_DISTANCE_FROM_START = 0.9
// If the word has more characters than the user typed, it should
// be penalised slightly.
//
// i.e. "html" is more likely than "html5" if I type "html".
//
// However, it may well be the case that there's a sensible secondary
// ordering (like alphabetical) that it makes sense to rely on when
// there are many prefix matches, so we don't make the penalty increase
// with the number of tokens.
const PENALTY_NOT_COMPLETE = 0.98
const IS_GAP_REGEXP = /[\\\/_+.#"@\[\(\{&]/
const COUNT_GAPS_REGEXP = /[\\\/_+.#"@\[\(\{&]/g
const IS_SPACE_REGEXP = /[\s-]/
const COUNT_SPACE_REGEXP = /[\s-]/g
function commandScoreInner(
string: string,
abbreviation: string,
lowerString: string,
lowerAbbreviation: string,
stringIndex: number,
abbreviationIndex: number,
memoizedResults: { [x: string]: number },
) {
if (abbreviationIndex === abbreviation.length) {
if (stringIndex === string.length) {
return SCORE_CONTINUE_MATCH
}
return PENALTY_NOT_COMPLETE
}
const memoizeKey = `${stringIndex},${abbreviationIndex}`
if (memoizedResults[memoizeKey] !== undefined) {
return memoizedResults[memoizeKey]
}
const abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex)
let index = lowerString.indexOf(abbreviationChar, stringIndex)
let highScore = 0
let score, transposedScore, wordBreaks, spaceBreaks
while (index >= 0) {
score = commandScoreInner(
string,
abbreviation,
lowerString,
lowerAbbreviation,
index + 1,
abbreviationIndex + 1,
memoizedResults,
)
if (score > highScore) {
if (index === stringIndex) {
score *= SCORE_CONTINUE_MATCH
} else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) {
score *= SCORE_NON_SPACE_WORD_JUMP
wordBreaks = string.slice(stringIndex, index - 1).match(COUNT_GAPS_REGEXP)
if (wordBreaks && stringIndex > 0) {
score *= PENALTY_SKIPPED ** wordBreaks.length
}
} else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) {
score *= SCORE_SPACE_WORD_JUMP
spaceBreaks = string.slice(stringIndex, index - 1).match(COUNT_SPACE_REGEXP)
if (spaceBreaks && stringIndex > 0) {
score *= PENALTY_SKIPPED ** spaceBreaks.length
}
} else {
score *= SCORE_CHARACTER_JUMP
if (stringIndex > 0) {
score *= PENALTY_SKIPPED ** (index - stringIndex)
}
}
if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) {
score *= PENALTY_CASE_MISMATCH
}
}
if (
(score < SCORE_TRANSPOSITION && lowerString.charAt(index - 1) === lowerAbbreviation.charAt(abbreviationIndex + 1)) ||
(lowerAbbreviation.charAt(abbreviationIndex + 1) === lowerAbbreviation.charAt(abbreviationIndex) && // allow duplicate letters. Ref #7428
lowerString.charAt(index - 1) !== lowerAbbreviation.charAt(abbreviationIndex))
) {
transposedScore = commandScoreInner(
string,
abbreviation,
lowerString,
lowerAbbreviation,
index + 1,
abbreviationIndex + 2,
memoizedResults,
)
if (transposedScore * SCORE_TRANSPOSITION > score) {
score = transposedScore * SCORE_TRANSPOSITION
}
}
if (score > highScore) {
highScore = score
}
index = lowerString.indexOf(abbreviationChar, index + 1)
}
memoizedResults[memoizeKey] = highScore
return highScore
}
function formatInput(string: string) {
// convert all valid space characters to space so they match each other
return string.toLowerCase().replace(COUNT_SPACE_REGEXP, ' ')
}
export function commandScore(string: string, abbreviation: string): number {
/* NOTE:
* in the original, we used to do the lower-casing on each recursive call, but this meant that toLowerCase()
* was the dominating cost in the algorithm, passing both is a little ugly, but considerably faster.
*/
return commandScoreInner(string, abbreviation, formatInput(string), formatInput(abbreviation), 0, 0, {})
}

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

@ -1,3 +1,672 @@
<script lang="ts" setup>
import { useMagicKeys, whenever } from '@vueuse/core'
// import { useNuxtApp } from '#app'
import { commandScore } from './command-score'
import type { ComputedRef, VNode } from '#imports'
import { iconMap, onClickOutside } from '#imports'
interface CmdAction {
id: string
title: string
hotkey?: string
parent?: string
handler?: Function
scopePayload?: any
icon?: VNode | string
keywords?: string[]
section?: string
is_default?: number | null
}
const props = defineProps<{
open: boolean
data: CmdAction[]
scope?: string
placeholder?: string
hotkey?: string
loadTemporaryScope?: (scope: { scope: string; data: any }) => void
}>()
const emits = defineEmits(['update:open', 'scope'])
const vOpen = useVModel(props, 'open', emits)
const { t } = useI18n()
const activeScope = ref('root')
const modalEl = ref<HTMLElement>()
const cmdInputEl = ref<HTMLInputElement>()
const cmdInput = ref('')
const { user } = useGlobal()
const renderCmdOrCtrlKey = () => {
return isMac() ? '⌘' : 'Ctrl'
}
const selected = ref<string>()
const formattedData: ComputedRef<(CmdAction & { weight: number })[]> = computed(() => {
const rt: (CmdAction & { weight: number })[] = []
for (const el of props.data) {
rt.push({
...el,
title: el?.section === 'Views' && el?.is_default ? t('title.defaultView') : el.title,
icon: el.section === 'Views' && el.is_default ? 'grid' : el.icon,
parent: el.parent || 'root',
weight: commandScore(
`${el.section}${el?.section === 'Views' && el?.is_default ? t('title.defaultView') : el.title}${el.keywords?.join()}`,
cmdInput.value,
),
})
}
return rt
})
const nestedScope = computed(() => {
const rt = []
let parent = activeScope.value
while (parent !== 'root') {
const parentEl = formattedData.value.find((el) => el.id === parent)
rt.push({
id: parent,
label: parentEl?.title,
icon: parentEl?.icon,
})
parent = parentEl?.parent || 'root'
}
return rt.reverse()
})
const isThereAnyActionInScope = (sc: string): boolean => {
return formattedData.value.some((el) => {
if (el.parent === sc) {
if (!el.handler) {
return isThereAnyActionInScope(el.id)
}
return true
}
return false
})
}
const getAvailableScopes = (sc: string) => {
const tempChildScopes = formattedData.value.filter((el) => el.parent === sc && !el.handler).map((el) => el.id)
for (const el of tempChildScopes) {
tempChildScopes.push(...getAvailableScopes(el))
}
return tempChildScopes
}
const activeScopes = computed(() => {
return getAvailableScopes(activeScope.value)
})
const actionList = computed(() => {
const sections = formattedData.value.filter((el) => el.section).map((el) => el.section)
formattedData.value.sort((a, b) => {
if (a.section && b.section) {
if (sections.indexOf(a.section) < sections.indexOf(b.section)) return -1
if (sections.indexOf(a.section) > sections.indexOf(b.section)) return 1
return 0
}
if (a.section) return 1
if (b.section) return -1
return 0
})
return formattedData.value.filter((el) => {
if (cmdInput.value === '') {
if (el.parent === activeScope.value) {
if (!el.handler) {
return isThereAnyActionInScope(el.id)
}
return true
}
return false
} else {
if (el.parent === activeScope.value || activeScopes.value.includes(el.parent || 'root')) {
if (!el.handler) {
return isThereAnyActionInScope(el.id)
}
return true
}
return false
}
})
})
const searchedActionList = computed(() => {
if (cmdInput.value === '') return actionList.value
actionList.value.sort((a, b) => {
if (a.weight > b.weight) return -1
if (a.weight < b.weight) return 1
return 0
})
return actionList.value
.filter((el) => el.weight > 0)
.sort((a, b) => b.section?.toLowerCase().localeCompare(a.section?.toLowerCase() as string) || 0)
})
const actionListGroupedBySection = computed(() => {
const rt: { [key: string]: CmdAction[] } = {}
searchedActionList.value.forEach((el) => {
if (el.section === 'hidden') return
if (el.section) {
if (!rt[el.section]) rt[el.section] = []
rt[el.section].push(el)
} else {
if (!rt.default) rt.default = []
rt.default.push(el)
}
})
return rt
})
const keys = useMagicKeys()
const setAction = (action: string) => {
selected.value = action
nextTick(() => {
const actionIndex = searchedActionList.value.findIndex((el) => el.id === action)
if (actionIndex === -1) return
if (actionIndex === 0) {
document.querySelector('.cmdk-actions')?.scrollTo({ top: 0, behavior: 'smooth' })
} else if (actionIndex === searchedActionList.value.length - 1) {
document.querySelector('.cmdk-actions')?.scrollTo({ top: 999999, behavior: 'smooth' })
} else {
document.querySelector('.cmdk-action.selected')?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
}
})
}
const selectFirstAction = () => {
if (searchedActionList.value.length > 0) {
setAction(searchedActionList.value[0].id)
} else {
selected.value = undefined
}
}
const setScope = (scope: string) => {
activeScope.value = scope
emits('scope', scope)
nextTick(() => {
cmdInputEl.value?.focus()
selectFirstAction()
})
}
const show = () => {
if (!user.value) return
if (props.scope === 'disabled') return
vOpen.value = true
cmdInput.value = ''
nextTick(() => {
setScope(props.scope || 'root')
})
}
const hide = () => {
vOpen.value = false
}
const fireAction = (action: CmdAction, preview = false) => {
if (preview) {
if (action?.scopePayload) {
setScope(action.scopePayload.scope)
if (action.scopePayload.data) {
props.loadTemporaryScope?.(action.scopePayload)
}
return
}
}
if (action?.handler) {
action.handler()
hide()
} else {
setScope(action.id)
}
}
whenever(keys.ctrl_k, () => {
show()
})
whenever(keys.meta_k, () => {
show()
})
whenever(keys.Escape, () => {
if (vOpen.value) hide()
})
whenever(keys.ctrl_l, () => {
if (vOpen.value) hide()
})
whenever(keys.meta_l, () => {
if (vOpen.value) hide()
})
whenever(keys.ctrl_j, () => {
if (vOpen.value) hide()
})
whenever(keys.meta_j, () => {
if (vOpen.value) hide()
})
whenever(keys.arrowup, () => {
if (vOpen.value) {
const idx = searchedActionList.value.findIndex((el) => el.id === selected.value)
if (idx > 0) {
setAction(searchedActionList.value[idx - 1].id)
} else if (idx === 0) {
setAction(searchedActionList.value[searchedActionList.value.length - 1].id)
}
}
})
whenever(keys.arrowdown, () => {
if (vOpen.value) {
const idx = searchedActionList.value.findIndex((el) => el.id === selected.value)
if (idx < searchedActionList.value.length - 1) {
setAction(searchedActionList.value[idx + 1].id)
} else if (idx === searchedActionList.value.length - 1) {
setAction(searchedActionList.value[0].id)
}
}
})
whenever(keys.Enter, () => {
if (vOpen.value) {
const selectedEl = formattedData.value.find((el) => el.id === selected.value)
cmdInput.value = ''
if (selectedEl) {
fireAction(selectedEl, keys.shift.value)
}
}
})
whenever(keys.Backspace, () => {
if (vOpen.value && cmdInput.value === '' && activeScope.value !== 'root') {
const activeEl = formattedData.value.find((el) => el.id === activeScope.value)
setScope(activeEl?.parent || 'root')
}
})
onClickOutside(modalEl, () => {
if (vOpen.value) hide()
})
defineExpose({
open: show,
close: hide,
setScope,
})
</script>
<template>
<div />
<div v-show="vOpen" class="cmdk-modal" :class="{ 'cmdk-modal-active': vOpen }">
<div ref="modalEl" class="cmdk-modal-content h-[25.25rem]">
<div class="cmdk-header">
<div class="cmdk-input-wrapper">
<GeneralIcon icon="search" class="h-4 w-4 text-gray-500" />
<div
v-for="el of nestedScope"
:key="`cmdk-breadcrumb-${el.id}`"
v-e="['a:cmdk:setScope']"
class="text-gray-600 text-sm cursor-pointer flex gap-1 items-center font-medium capitalize"
@click="setScope(el.id)"
>
<component
:is="(iconMap as any)[el.icon]"
v-if="el.icon && typeof el.icon === 'string' && (iconMap as any)[el.icon]"
class="cmdk-action-icon"
:class="{
'!text-blue-500': el.icon === 'grid',
'!text-purple-500': el.icon === 'form',
'!text-[#FF9052]': el.icon === 'kanban',
'!text-pink-500': el.icon === 'gallery',
}"
/>
<div v-else-if="el.icon" class="cmdk-action-icon max-w-4 flex items-center justify-center">
<LazyGeneralEmojiPicker class="!text-sm !h-4 !w-4" size="small" :emoji="el.icon" readonly />
</div>
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg">
<template #title>
{{ el.label }}
</template>
<span class="truncate capitalize mr-4">
{{ el.label }}
</span>
</a-tooltip>
<span class="text-gray-400 text-sm font-medium pl-1">/</span>
</div>
<input
ref="cmdInputEl"
v-model="cmdInput"
class="cmdk-input"
type="text"
:placeholder="props.placeholder"
@input="selectFirstAction"
/>
</div>
</div>
<div class="cmdk-body">
<div class="cmdk-actions nc-scrollbar-md">
<div v-if="searchedActionList.length === 0">
<div class="cmdk-action">
<div class="cmdk-action-content">No action found.</div>
</div>
</div>
<template v-else>
<div
v-for="[title, section] of Object.entries(actionListGroupedBySection)"
:key="`cmdk-section-${title}`"
class="cmdk-action-section border-t-1 border-gray-200"
>
<div v-if="title !== 'default'" class="cmdk-action-section-header capitalize">{{ title }}</div>
<div class="cmdk-action-section-body">
<div
v-for="act of section"
:key="act.id"
v-e="['a:cmdk:action']"
class="cmdk-action group"
:class="{ selected: selected === act.id }"
@mouseenter="setAction(act.id)"
@click="fireAction(act)"
>
<div class="cmdk-action-content w-full">
<component
:is="(iconMap as any)[act.icon]"
v-if="act.icon && typeof act.icon === 'string' && (iconMap as any)[act.icon]"
class="cmdk-action-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',
}"
/>
<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>
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg">
<template #title>
{{ act.title }}
</template>
<span class="truncate capitalize mr-4">
{{ 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
<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>
</div>
</template>
</div>
</div>
<div class="cmdk-footer absolute inset-x-0 bottom-0">
<div class="flex justify-center w-full py-2">
<div class="flex flex-grow-1 w-full text-sm items-center gap-2 justify-center">
<MdiFileOutline class="h-4 w-4" />
<span
@click.stop="
() => {
console.log('clicked')
}
"
class="cursor-pointer"
>
Document
</span>
<span class="bg-gray-100 px-1 rounded-md border-1 border-gray-300"> {{ renderCmdOrCtrlKey() }} + J </span>
</div>
<div class="flex flex-grow-1 text-brand-500 w-full text-sm items-center gap-2 justify-center">
<MdiMapMarkerOutline class="h-4 w-4" />
<span
@click.stop="
() => {
console.log('clicked')
}
"
class="cursor-pointer"
>
Quick Navigation
</span>
<span class="bg-brand-500 border-1 border-brand-500 text-sm text-white px-1 rounded-md">
{{ renderCmdOrCtrlKey() }} + K
</span>
</div>
<div class="flex flex-grow-1 w-full text-sm items-center gap-2 justify-center">
<MdiClockOutline class="h-4 w-4" />
<span
@click.stop="
() => {
console.log('clicked')
}
"
class="cursor-pointer"
>
Recent
</span>
<span class="bg-gray-100 px-1 rounded-md border-1 border-gray-300"> {{ renderCmdOrCtrlKey() }} + L </span>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss">
/* TODO Move styles to Windi Classes */
:root {
--cmdk-secondary-background-color: rgb(230, 230, 230);
--cmdk-secondary-text-color: rgb(101, 105, 111);
--cmdk-selected-background: rgb(245, 245, 245);
--cmdk-icon-color: var(--cmdk-secondary-text-color);
--cmdk-icon-size: 1.2em;
--cmdk-modal-background: #fff;
}
.cmdk-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.5);
z-index: 1000;
color: rgb(60, 65, 73);
font-size: 16px;
.cmdk-key {
display: flex;
align-items: center;
justify-content: center;
width: auto;
height: 1em;
font-size: 1.25em;
border-radius: 0.25em;
background: var(--cmdk-secondary-background-color);
color: var(--cmdk-secondary-text-color);
margin-right: 0.2em;
}
.cmdk-modal-content {
position: relative;
display: flex;
flex-direction: column;
flex-shrink: 1;
-webkit-box-flex: 1;
flex-grow: 1;
padding: 0;
overflow: hidden;
margin: auto;
box-shadow: rgb(0 0 0 / 50%) 0px 16px 70px;
top: 20%;
border-radius: 16px;
max-width: 640px;
background: var(--cmdk-modal-background);
}
.cmdk-input-wrapper {
@apply py-2 px-4 flex items-center gap-2;
}
.cmdk-input {
@apply text-sm;
flex-grow: 1;
flex-shrink: 0;
margin: 0px;
border: none;
appearance: none;
background: transparent;
outline: none;
box-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color) !important;
caret-color: pink;
color: rgb(60, 65, 73);
}
.cmdk-input::placeholder {
color: rgb(165, 165, 165);
opacity: 1;
}
.cmdk-input:-ms-input-placeholder {
color: rgb(165, 165, 165);
}
.cmdk-input::-ms-input-placeholder {
color: rgb(165, 165, 165);
}
.cmdk-actions {
max-height: 310px;
margin: 0px;
padding: 0.5em 0px;
list-style: none;
scroll-behavior: smooth;
overflow: auto;
position: relative;
--scrollbar-track: initial;
--scrollbar-thumb: initial;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
overflow: overlay;
scrollbar-width: auto;
scrollbar-width: thin;
--scrollbar-thumb: #e1e3e6;
--scrollbar-track: #fff;
.cmdk-action {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
outline: none;
transition: color 0s ease 0s;
width: 100%;
font-size: 0.9em;
border-left: 4px solid transparent;
.cmdk-keyboard {
display: hidden;
}
&.selected {
cursor: pointer;
background-color: #f4f4f5;
border-left: 4px solid var(--ant-primary-color);
outline: none;
.cmdk-keyboard {
display: flex;
}
}
.cmdk-action-content {
display: flex;
align-items: center;
flex-shrink: 0.01;
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0.75em 1em;
width: 100%;
}
.cmdk-action-icon {
margin-right: 0.4em;
}
}
.cmdk-action-section {
display: flex;
flex-direction: column;
width: 100%;
.cmdk-action-section-header {
display: flex;
align-items: center;
padding: 8px 16px;
font-size: 14px;
color: #6a7184;
}
}
}
.cmdk-footer {
display: flex;
border-top: 1px solid rgb(230, 230, 230);
background: rgba(242, 242, 242, 0.4);
font-size: 0.8em;
padding: 0.3em 0.6em;
color: var(--cmdk-secondary-text-color);
.cmdk-footer-left {
display: flex;
flex-grow: 1;
align-items: center;
.cmdk-key-helper {
display: flex;
align-items: center;
margin-right: 1.5em;
}
}
.cmdk-footer-right {
display: flex;
flex-grow: 1;
justify-content: flex-end;
align-items: center;
.nc-brand-icon {
margin-left: 0.5em;
}
}
}
}
</style>

329
packages/nc-gui/components/cmd-l/index.vue

@ -0,0 +1,329 @@
<script setup lang="ts">
import { onKeyUp, useDebounceFn, useMagicKeys, useVModel, whenever } from '@vueuse/core'
import { onClickOutside } from '#imports'
const props = defineProps<{
open: boolean
}>()
const emits = defineEmits(['update:open'])
const vOpen = useVModel(props, 'open', emits)
const modalEl = ref<HTMLElement>()
const { user } = useGlobal()
const viewStore = useViewsStore()
const { recentViews, activeView } = storeToRefs(viewStore)
const selected: Ref<string> = ref('')
const newView: Ref<
| {
viewId: string | null
tableId: string
baseId: string
}
| undefined
> = ref()
const changeView = useDebounceFn(
async ({ viewId, tableId, baseId }: { viewId: string | null; tableId: string; baseId: string }) => {
await viewStore.changeView({ viewId, tableId, baseId })
vOpen.value = false
},
200,
)
const keys = useMagicKeys()
const { current } = keys
onKeyUp('Enter', async () => {
if (vOpen.value && newView.value) {
await changeView({ viewId: newView.value.viewId, tableId: newView.value.tableId, baseId: newView.value.baseId })
}
})
function scrollToTarget() {
const element = document.querySelector('.cmdk-action.selected')
const headerOffset = 45
const elementPosition = element?.getBoundingClientRect().top
const offsetPosition = elementPosition! + window.pageYOffset - headerOffset
window.scrollTo({
top: offsetPosition,
behavior: 'smooth',
})
}
const renderCmdOrCtrlKey = () => {
return isMac() ? '⌘' : 'Ctrl'
}
const moveUp = () => {
if (!recentViews.value.length) return
const index = recentViews.value.findIndex((v) => v.tableID + v.viewName === selected.value)
if (index === 0) {
selected.value =
recentViews.value[recentViews.value.length - 1].tableID + recentViews.value[recentViews.value.length - 1].viewName
const cmdOption = recentViews.value[recentViews.value.length - 1]
newView.value = {
viewId: cmdOption.viewId ?? null,
tableId: cmdOption.tableID,
baseId: cmdOption.baseId,
}
document.querySelector('.actions')?.scrollTo({ top: 99999, behavior: 'smooth' })
} else {
selected.value = recentViews.value[index - 1].tableID + recentViews.value[index - 1].viewName
const cmdOption = recentViews.value[index - 1]
scrollToTarget()
newView.value = {
viewId: cmdOption.viewId ?? null,
tableId: cmdOption.tableID,
baseId: cmdOption.baseId,
}
}
}
const moveDown = () => {
if (!recentViews.value.length) return
const index = recentViews.value.findIndex((v) => v.tableID + v.viewName === selected.value)
if (index === recentViews.value.length - 1) {
selected.value = recentViews.value[0].tableID + recentViews.value[0].viewName
const cmdOption = recentViews.value[0]
newView.value = {
viewId: cmdOption.viewId ?? null,
tableId: cmdOption.tableID,
baseId: cmdOption.baseId,
}
document.querySelector('.actions')?.scrollTo({ top: 0, behavior: 'smooth' })
} else {
selected.value = recentViews.value[index + 1].tableID + recentViews.value[index + 1].viewName
const cmdOption = recentViews.value[index + 1]
scrollToTarget()
newView.value = {
viewId: cmdOption.viewId ?? null,
tableId: cmdOption.tableID,
baseId: cmdOption.baseId,
}
}
}
whenever(keys['Ctrl+Shift+L'], async () => {
if (!user.value) return
vOpen.value = true
moveUp()
})
whenever(keys['Meta+Shift+L'], async () => {
if (!user.value) return
vOpen.value = true
moveUp()
})
whenever(keys.ctrl_l, async () => {
if (!user.value) return
if (current.has('shift')) return
vOpen.value = true
moveDown()
})
whenever(keys.meta_l, async () => {
if (!user.value) return
if (current.has('shift')) return
vOpen.value = true
moveDown()
})
whenever(keys.arrowup, () => {
if (vOpen.value) moveUp()
})
whenever(keys.arrowdown, () => {
if (vOpen.value) moveDown()
})
const hide = () => {
vOpen.value = false
}
whenever(keys.Escape, () => {
if (vOpen.value) hide()
})
whenever(keys.ctrl_k, () => {
if (vOpen.value) hide()
})
whenever(keys.meta_k, () => {
if (vOpen.value) hide()
})
whenever(keys.ctrl_j, () => {
if (vOpen.value) hide()
})
whenever(keys.meta_j, () => {
if (vOpen.value) hide()
})
onClickOutside(modalEl, () => {
if (vOpen.value) hide()
})
onMounted(() => {
document.querySelector('.cmdOpt-list')?.focus()
if (!activeView.value) return
const index = recentViews.value.findIndex(
(v) => v.viewName === activeView?.value.name && v.tableID === activeView?.value.tableId,
)
if (index + 1 > recentViews.value.length) {
selected.value = recentViews.value[0].tableID + recentViews.value[0].viewName
} else {
selected.value = recentViews.value[index + 1].tableID + recentViews.value[index + 1].viewName
}
})
</script>
<template>
<div v-if="vOpen" class="cmdk-modal cmdl-modal" :class="{ 'cmdk-modal-active cmdl-modal-active': vOpen }">
<div ref="modalEl" class="cmdk-modal-content cmdl-modal-content relative h-[25.25rem]">
<div class="flex items-center bg-white w-full z-[50]">
<div class="text-sm p-4 text-gray-500">Recent Views</div>
</div>
<div class="flex flex-col shrink grow overflow-hidden shadow-[rgb(0_0_0_/_50%)_0px_16px_70px] max-w-[650px] p-0">
<div class="scroll-smooth actions overflow-auto nc-scrollbar-md relative m-0 px-0 py-2">
<div v-if="recentViews.length < 1" class="flex flex-col p-4 items-start justify-center text-md">No recent views</div>
<div v-else class="flex mb-10 flex-col cmdOpt-list w-full">
<div
v-for="cmdOption of recentViews"
:key="cmdOption.tableID + cmdOption.viewName"
v-e="['a:cmdL:changeView']"
:class="{
selected: selected === cmdOption.tableID + cmdOption.viewName,
}"
class="cmdk-action"
@click="changeView({ viewId: cmdOption.viewId, tableId: cmdOption.tableID, baseId: cmdOption.baseId })"
>
<div class="cmdk-action-content !flex w-full">
<div class="flex gap-2 w-full flex-grow-1 items-center">
<GeneralViewIcon :meta="{ type: cmdOption.viewType }" />
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg">
<template #title>
{{ cmdOption.viewName }}
</template>
<span class="max-w- truncate capitalize">
{{ cmdOption.viewName }}
</span>
</a-tooltip>
</div>
<div class="flex gap-2 bg-gray-100 px-2 py-1 rounded-md text-gray-600 items-center">
<component :is="iconMap.project" class="w-4 h-4 text-transparent" />
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg">
<template #title>
{{ cmdOption.baseName }}
</template>
<span class="max-w-32 truncate capitalize">
{{ cmdOption.baseName }}
</span>
</a-tooltip>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="cmdk-footer absolute !bg-white inset-x-0 bottom-0">
<div class="flex justify-center w-full py-2">
<div class="flex flex-grow-1 w-full text-sm items-center gap-2 justify-center">
<MdiFileOutline class="h-4 w-4" />
Document
<span class="bg-gray-100 px-1 rounded-md border-1 border-gray-300"> {{ renderCmdOrCtrlKey() }} + J </span>
</div>
<div class="flex flex-grow-1 w-full text-sm items-center gap-2 justify-center">
<MdiMapMarkerOutline class="h-4 w-4" />
Quick Navigation
<span class="bg-gray-100 px-1 rounded-md border-1 border-gray-300"> {{ renderCmdOrCtrlKey() }} + K </span>
</div>
<div class="flex flex-grow-1 text-brand-500 w-full text-sm items-center gap-2 justify-center">
<MdiClockOutline class="h-4 w-4" />
Recent
<span class="bg-brand-500 text-white px-1 rounded-md border-1 border-brand-500">
{{ renderCmdOrCtrlKey() }} + L
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss">
/* TODO Move styles to Windi Classes */
:root {
--cmdk-secondary-background-color: rgb(230, 230, 230);
--cmdk-secondary-text-color: rgb(101, 105, 111);
--cmdk-selected-background: rgb(245, 245, 245);
--cmdk-icon-color: var(--cmdk-secondary-text-color);
--cmdk-icon-size: 1.2em;
--cmdk-modal-background: #fff;
}
.cmdk-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.5);
z-index: 1000;
color: rgb(60, 65, 73);
font-size: 16px;
.cmdk-action {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
outline: none;
transition: color 0s ease 0s;
width: 100%;
font-size: 0.9em;
border-left: 4px solid transparent;
&.selected {
cursor: pointer;
background-color: rgb(248, 249, 251);
border-left: 4px solid #36f;
outline: none;
}
.cmdk-action-content {
display: flex;
align-items: center;
flex-shrink: 0.01;
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0.75em 1em;
width: 640px;
}
.cmdk-action-icon {
margin-right: 0.4em;
}
}
}
</style>

27
packages/nc-gui/components/dashboard/Sidebar/TopSection/Header.vue

@ -1 +1,26 @@
<template><span /></template>
<script setup lang="ts">
const { commandPalette } = useCommandPalette()
</script>
<template>
<NcButton
v-e="['c:quick-actions']"
type="text"
size="small"
class="nc-sidebar-top-button w-full !hover:bg-gray-200 !rounded-md !xs:hidden"
data-testid="nc-sidebar-search-btn"
:centered="false"
@click="commandPalette?.open()"
>
<div class="flex items-center gap-2">
<MaterialSymbolsSearch class="!h-3.9" />
Quick Actions
<div
class="inline-flex gap-1 justify-center text-xs px-[8px] py-[1px] uppercase border-1 border-gray-300 rounded-md bg-slate-150 text-gray-500"
>
<kbd class="text-[16px] mt-[0.5px]"></kbd>
<kbd class="!leading-4">K</kbd>
</div>
</div>
</NcButton>
</template>

77
packages/nc-gui/composables/useCommandPalette/commands.ts

@ -0,0 +1,77 @@
import { navigateTo } from '#imports'
export const homeCommands = [
{
id: 'user',
title: 'Account',
icon: 'account',
section: 'Accounts',
},
{
id: 'user_account-settings',
title: 'Account Settings',
icon: 'settings',
parent: 'user',
section: 'Account',
handler: () => {
navigateTo('/account/profile')
},
},
{
id: 'user_account-logout',
title: 'Logout',
icon: 'signout',
parent: 'user',
section: 'Account',
handler: () => {},
},
{
id: 'user_account-discord',
title: 'Discord',
icon: 'discord',
parent: 'user',
section: 'Community',
handler: () => {
navigateTo('https://discord.gg/8jX2GQn', { external: true })
},
},
{
id: 'user_account-twitter',
title: '(formerly Twitter)',
icon: 'twitter',
parent: 'user',
section: 'Community',
handler: () => {
navigateTo('https://twitter.com/NocoDB', { external: true })
},
},
{
id: 'user_account-reddit',
title: 'Reddit',
icon: 'reddit',
parent: 'user',
section: 'Community',
handler: () => {
navigateTo('https://www.reddit.com/r/NocoDB/', { external: true })
},
},
]
/*
Here is a list of all the available commands defined throughout the app.
Commands prefixed with a '-' are static commands that are always available.
Commands prefixed with a '+' are dynamic commands that are only available when the user is in a specific context.
Commands prefixed with a '*' are scopes
Commands:
* home (Navigate Home)
+ workspaces (Workspaces)
+ bases (Projects)
* workspace (Workspace)
+ tables (Tables)
+ views (Views)
* account_settings (Account Settings)
* account_settings-users (Users)
- account_settings-users-reset_password (Reset Password)
- account_settings-tokens (Tokens)
*/

170
packages/nc-gui/composables/useCommandPalette/index.ts

@ -1,20 +1,182 @@
import type { Ref } from 'vue'
import { homeCommands } from './commands'
interface CmdAction {
id: string
title: string
hotkey?: string
parent?: string
handler?: Function
icon?: VNode | string
keywords?: string[]
projectName?: string
section?: string
}
export const useCommandPalette = createSharedComposable(() => {
const { $api } = useNuxtApp()
const router = useRouter()
const route = router.currentRoute
const commandPalette = ref()
const refreshCommandPalette = createEventHook<void>()
const cmdPlaceholder = ref('Quick actions')
const activeScope: Ref<{ scope: string; data: any }> = ref({ scope: 'disabled', data: {} })
const cmdLoading = ref(false)
const cmdPlaceholder = ref('Search...')
const { token, user, signOut } = useGlobal()
const commands = ref({
homeCommands,
baseCommands: [],
} as Record<string, CmdAction[]>)
const staticData = computed(() => {
const staticCmd = commands.value.homeCommands
// Static Commands
staticCmd.map((cmd) => {
if (cmd.id === 'user') {
if (user.value && user.value.display_name && user.value.email) {
cmd.title = user.value.display_name ?? user.value.email.split('@')[0] ?? 'User'
}
} else if (cmd.id === 'user_account-logout') {
cmd.handler = async () => {
await signOut()
window.location.reload()
}
}
return cmd
})
if (activeScope.value.scope === 'root') return staticCmd
staticCmd.push(...commands.value.baseCommands)
return staticCmd
})
const dynamicData = ref<any>([])
const tempData = ref<any>([])
const loadedTemporaryScopes = ref<any>([])
const cmdData = computed(() => {
if (cmdLoading.value) {
return [{ id: 'loading', title: 'Loading...' }, ...staticData.value]
} else {
return [...dynamicData.value, ...staticData.value, ...tempData.value]
}
})
function processHandler(handler: { type: string; payload: string }) {
switch (handler.type) {
case 'navigate':
return () => navigateTo(handler.payload)
default:
break
}
}
async function loadTemporaryScope(scope: { scope: string; data: any }) {
if (loadedTemporaryScopes.value.find((s: any) => s.scope === scope.scope)) return
if (
activeScope.value.scope === scope.scope &&
Object.keys(scope.data).every((k) => activeScope.value.data[k] && scope.data[k] === activeScope.value.data[k])
)
return
$api.utils.commandPalette(scope).then((res) => {
const fetchData = res.map((item: any) => {
if (item.handler) item.handler = processHandler(item.handler)
return item
})
for (const d of fetchData) {
const fnd = tempData.value.find((t: any) => t.id === d.id)
if (fnd) {
Object.assign(fnd, d)
} else {
tempData.value.push(d)
}
}
loadedTemporaryScopes.value.push(scope)
})
}
async function loadScope() {
if (activeScope.value.scope === 'disabled') {
activeScope.value = { scope: activeScope.value.scope, data: activeScope.value.data }
return
}
dynamicData.value = []
tempData.value = []
loadedTemporaryScopes.value = []
cmdLoading.value = true
$api.utils
.commandPalette(activeScope.value)
.then((res) => {
dynamicData.value = res.map((item: any) => {
if (item.handler) item.handler = processHandler(item.handler)
return item
})
cmdLoading.value = false
})
.catch((e) => {
cmdLoading.value = false
console.log(e)
})
}
const cmdData = computed(() => {})
refreshCommandPalette.on(() => {
loadScope()
})
const activeScope = computed(() => ({} as any))
watch(
() => route.value.params,
() => {
// if user is not authenticated, don't load scope
if (!token.value) return
const loadTemporaryScope = (..._args: any) => {}
if (route.value.params.typeOrId && typeof route.value.params.typeOrId === 'string') {
if (route.value.params.typeOrId === 'base') {
if (activeScope.value.scope === 'disabled') return
activeScope.value = { scope: 'disabled', data: {} }
loadScope()
} else if (route.value.params.typeOrId.startsWith('w')) {
if (activeScope.value.data.workspace_id === route.value.params.typeOrId) return
activeScope.value = {
scope: `ws-${route.value.params.typeOrId}`,
data: { workspace_id: route.value.params.typeOrId },
}
loadScope()
}
} else {
if (route.value.path.startsWith('/account')) {
if (activeScope.value.scope === 'account_settings') return
activeScope.value = { scope: 'account_settings', data: {} }
loadScope()
} else {
if (activeScope.value.scope === 'root') return
activeScope.value = { scope: 'root', data: {} }
loadScope()
}
}
},
{ immediate: true, deep: true },
)
return {
commandPalette,
cmdData,
activeScope,
loadScope,
cmdPlaceholder,
refreshCommandPalette: refreshCommandPalette.trigger,
loadTemporaryScope,

6
packages/nc-gui/nuxt.config.ts

@ -101,6 +101,11 @@ export default defineNuxtConfig({
content: './link-preview.webp',
},
],
script: [
{
src: '/js/typesense-docsearch.js',
},
],
},
},
@ -110,6 +115,7 @@ export default defineNuxtConfig({
'virtual:windi-devtools',
'~/assets/css/global.css',
'~/assets/style.scss',
'~/assets/css/typesense-docsearch.css',
],
runtimeConfig: {

7
packages/nc-gui/public/js/typesense-docsearch.js

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save