337 lines · 9.0 KB · TypeScript
RawDownload
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"import { createStore } from "solid-js/store"import { createSimpleContext } from "@synsci/ui/context"import { useDialog } from "@synsci/ui/context/dialog"import { useLanguage } from "@/context/language"import { useSettings } from "@/context/settings"import { Persist, persisted } from "@/utils/persist" const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const PALETTE_ID = "command.palette"const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"const SUGGESTED_PREFIX = "suggested." function actionId(id: string) {  if (!id.startsWith(SUGGESTED_PREFIX)) return id  return id.slice(SUGGESTED_PREFIX.length)} function normalizeKey(key: string) {  if (key === ",") return "comma"  if (key === "+") return "plus"  if (key === " ") return "space"  return key.toLowerCase()} function signature(key: string, ctrl: boolean, meta: boolean, shift: boolean, alt: boolean) {  const mask = (ctrl ? 1 : 0) | (meta ? 2 : 0) | (shift ? 4 : 0) | (alt ? 8 : 0)  return `${key}:${mask}`} function signatureFromEvent(event: KeyboardEvent) {  return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)} export type KeybindConfig = string export interface Keybind {  key: string  ctrl: boolean  meta: boolean  shift: boolean  alt: boolean} export interface CommandOption {  id: string  title: string  description?: string  category?: string  keybind?: KeybindConfig  slash?: string  suggested?: boolean  disabled?: boolean  onSelect?: (source?: "palette" | "keybind" | "slash") => void  onHighlight?: () => (() => void) | void} export type CommandCatalogItem = {  title: string  description?: string  category?: string  keybind?: KeybindConfig  slash?: string} export function parseKeybind(config: string): Keybind[] {  if (!config || config === "none") return []   return config.split(",").map((combo) => {    const parts = combo.trim().toLowerCase().split("+")    const keybind: Keybind = {      key: "",      ctrl: false,      meta: false,      shift: false,      alt: false,    }     for (const part of parts) {      switch (part) {        case "ctrl":        case "control":          keybind.ctrl = true          break        case "meta":        case "cmd":        case "command":          keybind.meta = true          break        case "mod":          if (IS_MAC) keybind.meta = true          else keybind.ctrl = true          break        case "alt":        case "option":          keybind.alt = true          break        case "shift":          keybind.shift = true          break        default:          keybind.key = part          break      }    }     return keybind  })} export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {  const eventKey = normalizeKey(event.key)   for (const kb of keybinds) {    const keyMatch = kb.key === eventKey    const ctrlMatch = kb.ctrl === (event.ctrlKey || false)    const metaMatch = kb.meta === (event.metaKey || false)    const shiftMatch = kb.shift === (event.shiftKey || false)    const altMatch = kb.alt === (event.altKey || false)     if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {      return true    }  }   return false} export function formatKeybind(config: string): string {  if (!config || config === "none") return ""   const keybinds = parseKeybind(config)  if (keybinds.length === 0) return ""   const kb = keybinds[0]  const parts: string[] = []   if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")  if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")  if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")  if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")   if (kb.key) {    const keys: Record<string, string> = {      arrowup: "↑",      arrowdown: "↓",      arrowleft: "←",      arrowright: "→",      comma: ",",      plus: "+",      space: "Space",    }    const key = kb.key.toLowerCase()    const displayKey = keys[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1))    parts.push(displayKey)  }   return IS_MAC ? parts.join("") : parts.join("+")} export const { use: useCommand, provider: CommandProvider } = createSimpleContext({  name: "Command",  init: () => {    const dialog = useDialog()    const settings = useSettings()    const language = useLanguage()    const [store, setStore] = createStore({      registrations: [] as Accessor<CommandOption[]>[],      suspendCount: 0,    })     const [catalog, setCatalog, _, catalogReady] = persisted(      Persist.global("command.catalog.v1"),      createStore<Record<string, CommandCatalogItem>>({}),    )     const bind = (id: string, def: KeybindConfig | undefined) => {      const custom = settings.keybinds.get(actionId(id))      const config = custom ?? def      if (!config || config === "none") return      return config    }     const registered = createMemo(() => {      const seen = new Set<string>()      const all: CommandOption[] = []       for (const reg of store.registrations) {        for (const opt of reg()) {          if (seen.has(opt.id)) continue          seen.add(opt.id)          all.push(opt)        }      }       return all    })     createEffect(() => {      if (!catalogReady()) return       for (const opt of registered()) {        const id = actionId(opt.id)        setCatalog(id, {          title: opt.title,          description: opt.description,          category: opt.category,          keybind: opt.keybind,          slash: opt.slash,        })      }    })     const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))     const options = createMemo(() => {      const resolved = registered().map((opt) => ({        ...opt,        keybind: bind(opt.id, opt.keybind),      }))       const suggested = resolved.filter((x) => x.suggested && !x.disabled)       return [        ...suggested.map((x) => ({          ...x,          id: SUGGESTED_PREFIX + x.id,          category: language.t("command.category.suggested"),        })),        ...resolved,      ]    })     const suspended = () => store.suspendCount > 0     const palette = createMemo(() => {      const config = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND      const keybinds = parseKeybind(config)      return new Set(keybinds.map((kb) => signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)))    })     const keymap = createMemo(() => {      const map = new Map<string, CommandOption>()      for (const option of options()) {        if (option.id.startsWith(SUGGESTED_PREFIX)) continue        if (option.disabled) continue        if (!option.keybind) continue         const keybinds = parseKeybind(option.keybind)        for (const kb of keybinds) {          if (!kb.key) continue          const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)          if (map.has(sig)) continue          map.set(sig, option)        }      }      return map    })     const run = (id: string, source?: "palette" | "keybind" | "slash") => {      for (const option of options()) {        if (option.id === id || option.id === "suggested." + id) {          option.onSelect?.(source)          return        }      }    }     const showPalette = () => {      run("file.open", "palette")    }     const handleKeyDown = (event: KeyboardEvent) => {      if (suspended() || dialog.active) return       const sig = signatureFromEvent(event)       if (palette().has(sig)) {        event.preventDefault()        showPalette()        return      }       const option = keymap().get(sig)      if (!option) return      event.preventDefault()      option.onSelect?.("keybind")    }     onMount(() => {      document.addEventListener("keydown", handleKeyDown)    })     onCleanup(() => {      document.removeEventListener("keydown", handleKeyDown)    })     return {      register(cb: () => CommandOption[]) {        const results = createMemo(cb)        setStore("registrations", (arr) => [results, ...arr])        onCleanup(() => {          setStore("registrations", (arr) => arr.filter((x) => x !== results))        })      },      trigger(id: string, source?: "palette" | "keybind" | "slash") {        run(id, source)      },      keybind(id: string) {        if (id === PALETTE_ID) {          return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)        }         const base = actionId(id)        const option = options().find((x) => actionId(x.id) === base)        if (option?.keybind) return formatKeybind(option.keybind)         const meta = catalog[base]        const config = bind(base, meta?.keybind)        if (!config) return ""        return formatKeybind(config)      },      show: showPalette,      keybinds(enabled: boolean) {        setStore("suspendCount", (count) => count + (enabled ? -1 : 1))      },      suspended,      get catalog() {        return catalogOptions()      },      get options() {        return options()      },    }  },})