CmdK.svelte 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. <script lang="ts">
  2. import { onMount } from 'svelte';
  3. import { fly, fade } from 'svelte/transition';
  4. import { menuList, type MenuList, type MenuListChild } from '../../data/menuList';
  5. import { page } from '$app/stores';
  6. import { isCmdKStore } from '../../store';
  7. import { goto } from '$app/navigation';
  8. let menuChildList: MenuListChild[] = $state([]); //展开的菜单列表
  9. let cmdKValue = $state(''); //cmd+k 搜索框的值
  10. let currentIndex = $state(0); //当前选中的索引
  11. let currentTab: number = $state(0); //当前选中的 tab
  12. let cmdFocus = $state(false); //cmd+k 搜索框是否获取焦点
  13. let cmdKInput: HTMLInputElement | null = $state(null); //cmd+k 搜索框的 dom
  14. let latelyList: MenuListChild[] = $state([]); //最近使用的列表
  15. let isDemoShake = $state(false); //是否显示 demo 抖动动画
  16. let isGuideShake = $state(false); //是否显示 guide 抖动动画
  17. const isZh = localStorage.getItem('lang') === 'zh_CN';
  18. const params = new URLSearchParams('?');
  19. let cmdKList = $derived(
  20. cmdKValue === '' ? latelyList : menuChildList.filter((item: any) => item.alias.indexOf(cmdKValue.toLowerCase()) > -1)
  21. ); //cmd+k 搜索框的列表
  22. let currentMenu = $derived(cmdKList[currentIndex]); //当前选中的菜单
  23. //cmd+k 搜索框的事件
  24. const cmdKFun = (e: KeyboardEvent) => {
  25. // 判断是 Windows 还是 Mac,cmd+k 在 Windows 下是 ctrl+k,监听 e.ctrlKey,Mac 监听 metaKey
  26. const isMac = /macintosh|mac os x/i.test(navigator.userAgent);
  27. const isWindows = /windows|win32/i.test(navigator.userAgent);
  28. //按下 cmd+k 触发事件
  29. if (e.key === 'k' && ((isMac && e.metaKey) || (isWindows && e.ctrlKey))) {
  30. const latelyListStr = localStorage.getItem('latelyList');
  31. latelyList = latelyListStr ? JSON.parse(latelyListStr) : [];
  32. if ($isCmdKStore) {
  33. isCmdKStore.set(false);
  34. cmdFocus = false;
  35. } else {
  36. isCmdKStore.set(true);
  37. cmdFocus = true;
  38. cmdKValue = '';
  39. currentIndex = 0;
  40. currentTab = 0;
  41. setTimeout(() => {
  42. cmdKInput?.focus();
  43. }, 0);
  44. }
  45. e.preventDefault();
  46. }
  47. //按下 esc 触发事件
  48. if ($isCmdKStore && e.key === 'Escape') {
  49. // 阻止默认事件
  50. e.preventDefault();
  51. //如果搜索框没有获取焦点,按下 esc 获取焦点并选定搜索框的值
  52. if (!cmdFocus) {
  53. cmdFocus = true;
  54. setTimeout(() => {
  55. cmdKInput?.focus();
  56. cmdKInput?.select();
  57. }, 0);
  58. } else {
  59. isCmdKStore.set(false);
  60. cmdFocus = false;
  61. }
  62. }
  63. //显示 cmd+k 搜索框时,按下上下键触发事件
  64. if ($isCmdKStore && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
  65. //阻止默认事件,防止下层滚动
  66. e.preventDefault();
  67. cmdFocus = false;
  68. cmdKInput?.blur();
  69. if (e.key === 'ArrowUp') {
  70. if (currentIndex === 0 && currentTab === 0) {
  71. isDemoShake = true;
  72. setTimeout(() => {
  73. isDemoShake = false;
  74. }, 820);
  75. return;
  76. }
  77. if (currentTab === 0) {
  78. currentIndex = currentIndex === 0 ? cmdKList.length - 1 : currentIndex - 1;
  79. currentTab = 2;
  80. } else {
  81. currentTab--;
  82. }
  83. }
  84. if (e.key === 'ArrowDown') {
  85. if (currentIndex === cmdKList.length - 1 && currentTab === 2) {
  86. isGuideShake = true;
  87. setTimeout(() => {
  88. isGuideShake = false;
  89. }, 820);
  90. return;
  91. }
  92. if (currentTab === 2) {
  93. currentIndex = currentIndex === cmdKList.length - 1 ? 0 : currentIndex + 1;
  94. currentTab = 0;
  95. } else {
  96. currentTab++;
  97. }
  98. }
  99. }
  100. //显示 cmd+k 搜索框时,按下回车键触发事件
  101. if ($isCmdKStore && e.key === 'Enter') {
  102. //将当前选中的菜单添加到最近使用的列表中,并存储到 localStorage 中,最多存储 3 条
  103. if (latelyList.length > 0 && latelyList[0]?.nav) {
  104. let index = latelyList.findIndex((item) => item.alias === currentMenu.alias);
  105. if (index > -1) {
  106. latelyList.splice(index, 1);
  107. }
  108. latelyList.unshift(currentMenu);
  109. if (latelyList.length > 3) {
  110. latelyList.pop();
  111. }
  112. } else {
  113. latelyList.push(currentMenu);
  114. }
  115. if (latelyList.length > 0 && latelyList[0]?.nav) {
  116. // 去重
  117. latelyList = Array.from(new Set(latelyList));
  118. localStorage.setItem('latelyList', JSON.stringify(latelyList));
  119. }
  120. isCmdKStore.set(false);
  121. cmdFocus = false;
  122. cmdKValue = '';
  123. params.set('nav', currentMenu.nav);
  124. params.set('tab', currentTab.toString());
  125. goto(`/components?nav=${currentMenu.nav}&tab=${currentTab}`);
  126. if ($page.url.pathname.includes('/components')) {
  127. setTimeout(() => {
  128. window.location.reload();
  129. }, 0);
  130. }
  131. }
  132. };
  133. //点击搜索框的事件
  134. const clickCmdKFun = (item: MenuListChild, index: number) => {
  135. //将当前选中的菜单添加到最近使用的列表中,并存储到 localStorage 中,最多存储 5 条
  136. if (latelyList.length === 0) {
  137. latelyList.push(item);
  138. } else {
  139. let index = latelyList.findIndex((item) => item.alias === item.alias);
  140. if (index > -1) {
  141. latelyList.splice(index, 1);
  142. }
  143. latelyList.unshift(item);
  144. if (latelyList.length > 3) {
  145. latelyList.pop();
  146. }
  147. }
  148. localStorage.setItem('latelyList', JSON.stringify(latelyList));
  149. isCmdKStore.set(false);
  150. cmdFocus = false;
  151. cmdKValue = '';
  152. params.set('nav', item.nav);
  153. params.set('tab', index.toString());
  154. if ($page.url.pathname === '/components') {
  155. setTimeout(() => {
  156. window.location.reload();
  157. }, 0);
  158. }
  159. };
  160. //关闭 cmd+k 搜索框
  161. const closeCmdKFun = () => {
  162. isCmdKStore.set(false);
  163. cmdFocus = false;
  164. };
  165. $effect(() => {
  166. if ($isCmdKStore) {
  167. const latelyListStr = localStorage.getItem('latelyList');
  168. latelyList = latelyListStr ? JSON.parse(latelyListStr) : [];
  169. isCmdKStore.set(true);
  170. cmdFocus = true;
  171. currentIndex = 0;
  172. currentTab = 0;
  173. setTimeout(() => {
  174. cmdKInput?.focus();
  175. }, 0);
  176. }
  177. });
  178. //数组二级组成新数组
  179. const ArrChildFun = (arr: MenuList[]) => {
  180. let newArr = [];
  181. for (let e = 0; e < arr.length; e++) {
  182. newArr.push(...arr[e].childs);
  183. }
  184. return newArr;
  185. };
  186. onMount(() => {
  187. menuChildList = ArrChildFun(menuList);
  188. });
  189. </script>
  190. <svelte:window onkeydown={cmdKFun} />
  191. <!-- svelte-ignore a11y_click_events_have_key_events -->
  192. <!-- svelte-ignore a11y_no_static_element_interactions -->
  193. {#if $isCmdKStore}
  194. <div
  195. transition:fade={{ duration: 50 }}
  196. class="fixed left-0 top-0 flex h-screen w-screen flex-col bg-black/20 pt-20 text-left backdrop-blur"
  197. style="z-index: 10000"
  198. onclick={closeCmdKFun}
  199. >
  200. <div
  201. in:fly={{ y: 100, duration: 300, delay: 50 }}
  202. class="mx-auto w-1/2 rounded-xl bg-white shadow-lg dark:bg-black"
  203. onclick={(e: Event) => e.stopPropagation()}
  204. >
  205. <div class="flex items-center border-b border-black/10 px-4 py-3 text-sm text-gray-500 dark:border-white/10">
  206. <div class="">
  207. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" style="fill: currentColor;">
  208. <path fill="none" d="M0 0h24v24H0z" />
  209. <path
  210. d="M11 2c4.968 0 9 4.032 9 9s-4.032 9-9 9-9-4.032-9-9 4.032-9 9-9zm0 16c3.867 0 7-3.133 7-7 0-3.868-3.133-7-7-7-3.868 0-7 3.132-7 7 0 3.867 3.132 7 7 7zm8.485.071l2.829 2.828-1.415 1.415-2.828-2.829 1.414-1.414z"
  211. />
  212. </svg>
  213. </div>
  214. <div class="grow px-3 text-gray-800 dark:text-gray-200">
  215. <input
  216. bind:this={cmdKInput}
  217. bind:value={cmdKValue}
  218. class="focus:ring-b caret-primary dark:caret-dark w-full placeholder:text-black/20 focus:outline-none dark:bg-black dark:placeholder:text-white/10"
  219. type="text"
  220. placeholder={isZh ? '请输入组件关键字' : 'Please enter the component keyword'}
  221. />
  222. </div>
  223. <div class="rounded border border-black/10 px-2 py-1 text-xs font-bold dark:border-white/10">ESC</div>
  224. </div>
  225. <div class="overflow-y-auto px-6 pb-6" style="max-height: {(document.documentElement.clientHeight * 3) / 4}px">
  226. {#if cmdKValue === ''}
  227. <div class="mt-2 text-xs text-black/50 dark:text-white/30">
  228. {#if latelyList.length === 0}
  229. {isZh ? `最近无搜索,请输入关键字搜索。` : `No recent searches, please enter a keyword to search.`}
  230. {:else}
  231. {isZh ? `最近搜索 ${latelyList.length} 条结果` : `Recent search ${latelyList.length} results`}
  232. {/if}
  233. </div>
  234. {/if}
  235. {#if cmdKValue !== ''}
  236. {#if cmdKList.length === 0}
  237. <div class="mt-2 text-xs text-black/50 dark:text-white/30">
  238. {isZh ? '没有搜索到结果' : 'No results found'}
  239. </div>
  240. {:else}
  241. <div class="mt-2 text-xs text-black/50 dark:text-white/30">
  242. {isZh
  243. ? `搜索到包含 "${cmdKValue}" 的 ${cmdKList.length} 条结果`
  244. : `Found ${cmdKList.length} results containing "${cmdKValue}"`}
  245. </div>
  246. {/if}
  247. {/if}
  248. {#each cmdKList as item, index}
  249. <div>
  250. <div class="mt-4 py-2 text-lg font-bold">{isZh ? item?.title : item?.title_en}</div>
  251. <div class="border-l border-black/10 pl-3 dark:border-white/10">
  252. <button
  253. class="my-2 flex w-full cursor-pointer justify-between rounded border py-2 pl-4 {index === currentIndex && currentTab === 0
  254. ? 'border-primary bg-primary dark:border-dark dark:bg-dark text-white dark:text-black'
  255. : 'text-balck border-primary-50 dark:border-dark-950 dark:text-white'}"
  256. class:animate-shake={isDemoShake && index === 0}
  257. onclick={() => clickCmdKFun(item, 0)}
  258. >
  259. <div>{isZh ? '示例' : 'Demo'}</div>
  260. <div>
  261. <svg
  262. class={index === currentIndex && currentTab === 0 ? 'fill-white dark:fill-black' : 'fill-gray-500'}
  263. xmlns="http://www.w3.org/2000/svg"
  264. viewBox="0 0 24 24"
  265. width="24"
  266. height="24"
  267. >
  268. <path fill="none" d="M0 0h24v24H0z" />
  269. <path d="M12.172 12L9.343 9.172l1.414-1.415L15 12l-4.243 4.243-1.414-1.415z" />
  270. </svg>
  271. </div>
  272. </button>
  273. <button
  274. class="my-2 flex w-full cursor-pointer justify-between rounded border py-2 pl-4 {index === currentIndex && currentTab === 1
  275. ? 'border-primary bg-primary dark:border-dark dark:bg-dark text-white dark:text-black'
  276. : 'text-balck border-primary-50 dark:border-dark-950 dark:text-white'}"
  277. onclick={() => clickCmdKFun(item, 1)}
  278. >
  279. <div>API</div>
  280. <div>
  281. <svg
  282. class={index === currentIndex && currentTab === 1 ? 'fill-white dark:fill-black' : 'fill-gray-500'}
  283. xmlns="http://www.w3.org/2000/svg"
  284. viewBox="0 0 24 24"
  285. width="24"
  286. height="24"
  287. >
  288. <path fill="none" d="M0 0h24v24H0z" />
  289. <path d="M12.172 12L9.343 9.172l1.414-1.415L15 12l-4.243 4.243-1.414-1.415z" />
  290. </svg>
  291. </div>
  292. </button>
  293. <button
  294. class="my-2 flex w-full cursor-pointer justify-between rounded border py-2 pl-4 {index === currentIndex && currentTab === 2
  295. ? 'border-primary bg-primary dark:border-dark dark:bg-dark text-white dark:text-black'
  296. : 'text-balck border-primary-50 dark:border-dark-950 dark:text-white'}"
  297. class:animate-shake={isGuideShake && index === cmdKList.length - 1}
  298. onclick={() => clickCmdKFun(item, 2)}
  299. >
  300. <div>{isZh ? '指南' : 'Guide'}</div>
  301. <div>
  302. <svg
  303. class={index === currentIndex && currentTab === 2 ? 'fill-white dark:fill-black' : 'fill-gray-500'}
  304. xmlns="http://www.w3.org/2000/svg"
  305. viewBox="0 0 24 24"
  306. width="24"
  307. height="24"
  308. >
  309. <path fill="none" d="M0 0h24v24H0z" />
  310. <path d="M12.172 12L9.343 9.172l1.414-1.415L15 12l-4.243 4.243-1.414-1.415z" />
  311. </svg>
  312. </div>
  313. </button>
  314. </div>
  315. </div>
  316. {/each}
  317. </div>
  318. <div class="flex gap-4 border-t border-black/10 px-4 py-2 text-xs opacity-60 dark:border-white/10">
  319. <div class="flex gap-1">
  320. <div class="h-4 w-4">
  321. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="fill-black dark:fill-white">
  322. <path
  323. d="M19.0003 13.9999L19.0004 5.00003L17.0004 5L17.0003 11.9999L6.82845 12L10.7782 8.05027L9.36396 6.63606L3 13L9.36396 19.364L10.7782 17.9498L6.8284 14L19.0003 13.9999Z"
  324. />
  325. </svg>
  326. </div>
  327. {isZh ? '选择' : 'Select'}
  328. </div>
  329. <div class="flex gap-1">
  330. <div class="h-4 w-4">
  331. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="fill-black dark:fill-white"
  332. ><path
  333. d="M12.9999 16.1716L18.3638 10.8076L19.778 12.2218L11.9999 20L4.22168 12.2218L5.63589 10.8076L10.9999 16.1716V4H12.9999V16.1716Z"
  334. /></svg
  335. >
  336. </div>
  337. {isZh ? '向下' : 'Down'}
  338. </div>
  339. <div class="flex gap-1">
  340. <div class="h-4 w-4">
  341. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="fill-black dark:fill-white"
  342. ><path
  343. d="M12.9999 7.82843V20H10.9999V7.82843L5.63589 13.1924L4.22168 11.7782L11.9999 4L19.778 11.7782L18.3638 13.1924L12.9999 7.82843Z"
  344. /></svg
  345. >
  346. </div>
  347. {isZh ? '向上' : 'Up'}
  348. </div>
  349. <div class="flex gap-1">
  350. <div class="h-4">ESC</div>
  351. {isZh ? '关闭' : 'Close'}
  352. </div>
  353. </div>
  354. </div>
  355. </div>
  356. {/if}