Files
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

233 lines
7.6 KiB
TypeScript

'use client'
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from 'react'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { useRouter } from 'next/navigation'
import { isMacPlatform } from '@/lib/core/utils/platform'
const logger = createLogger('GlobalCommands')
export interface ParsedShortcut {
key: string
mod?: boolean
ctrl?: boolean
meta?: boolean
shift?: boolean
alt?: boolean
}
export interface GlobalCommand {
id?: string
shortcut: string
allowInEditable?: boolean
handler: (event: KeyboardEvent) => void
}
interface RegistryCommand extends GlobalCommand {
id: string
parsed: ParsedShortcut
}
interface GlobalCommandsContextValue {
register: (commands: GlobalCommand[]) => () => void
invoke: (id: string) => boolean
}
const GlobalCommandsContext = createContext<GlobalCommandsContextValue | null>(null)
function parseShortcut(shortcut: string): ParsedShortcut {
const parts = shortcut.split('+').map((p) => p.trim())
const modifiers = new Set(parts.slice(0, -1).map((p) => p.toLowerCase()))
const last = parts[parts.length - 1]
return {
key: last.length === 1 ? last.toLowerCase() : last,
mod: modifiers.has('mod'),
ctrl: modifiers.has('ctrl'),
meta: modifiers.has('meta') || modifiers.has('cmd') || modifiers.has('command'),
shift: modifiers.has('shift'),
alt: modifiers.has('alt') || modifiers.has('option'),
}
}
/**
* Maps a KeyboardEvent.code value to the logical key name used in shortcut definitions.
* Needed for international keyboard layouts where e.key may produce unexpected characters
* (e.g. macOS Option+letter yields 'å' instead of 'a', dead keys yield 'Dead').
*/
function codeToKey(code: string): string | undefined {
if (code.startsWith('Key')) return code.slice(3).toLowerCase()
if (code.startsWith('Digit')) return code.slice(5)
return undefined
}
function matchesShortcut(e: KeyboardEvent, parsed: ParsedShortcut): boolean {
const isMac = isMacPlatform()
const expectedCtrl = parsed.ctrl || (parsed.mod ? !isMac : false)
const expectedMeta = parsed.meta || (parsed.mod ? isMac : false)
const eventKey = e.key.length === 1 ? e.key.toLowerCase() : e.key
const keyMatches = eventKey === parsed.key || codeToKey(e.code) === parsed.key
return (
keyMatches &&
!!e.ctrlKey === !!expectedCtrl &&
!!e.metaKey === !!expectedMeta &&
!!e.shiftKey === !!parsed.shift &&
!!e.altKey === !!parsed.alt
)
}
/** Platform-resolved signature of a shortcut, so `Mod+K`, `Cmd+K`, and `Meta+K` compare equal on mac. */
function shortcutSignature(parsed: ParsedShortcut, isMac: boolean): string {
const ctrl = parsed.ctrl || (parsed.mod ? !isMac : false)
const meta = parsed.meta || (parsed.mod ? isMac : false)
return `${parsed.key}|${+ctrl}|${+meta}|${+!!parsed.shift}|${+!!parsed.alt}`
}
/**
* Whether `element` is an editable region for the purposes of the editable guard.
* `isContentEditable` is the browser's computed editability, so focusable descendants of a
* `contenteditable` root count as editable, while `contenteditable="false"` (e.g. a rich-text
* editor in read-only mode) does not — commands with `allowInEditable: false` still fire there.
*/
function isEditableElement(element: Element | null): boolean {
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) return true
return element instanceof HTMLElement && element.isContentEditable
}
/**
* Whether the focused element (or an ancestor) declares it owns `parsed` via a comma-separated
* `data-owned-shortcuts` attribute (e.g. a rich-text editor that binds `Mod+K` to links). Such a
* shortcut is left for that element to handle instead of firing the global command.
*/
function focusedElementOwnsShortcut(parsed: ParsedShortcut, isMac: boolean): boolean {
const active = document.activeElement
const owner = active instanceof HTMLElement ? active.closest('[data-owned-shortcuts]') : null
if (!owner) return false
const target = shortcutSignature(parsed, isMac)
return (owner.getAttribute('data-owned-shortcuts') ?? '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
.some((entry) => shortcutSignature(parseShortcut(entry), isMac) === target)
}
export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
const registryRef = useRef<Map<string, RegistryCommand>>(new Map())
const isMac = useMemo(() => isMacPlatform(), [])
const router = useRouter()
const register = useCallback((commands: GlobalCommand[]) => {
const createdIds: string[] = []
for (const cmd of commands) {
const id = cmd.id ?? generateId()
const parsed = parseShortcut(cmd.shortcut)
registryRef.current.set(id, {
...cmd,
id,
parsed,
allowInEditable: cmd.allowInEditable ?? true,
})
createdIds.push(id)
}
return () => {
for (const id of createdIds) {
registryRef.current.delete(id)
}
}
}, [])
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.isComposing) return
for (const [, cmd] of registryRef.current) {
if (!cmd.allowInEditable && isEditableElement(document.activeElement)) continue
if (matchesShortcut(e, cmd.parsed)) {
if (focusedElementOwnsShortcut(cmd.parsed, isMac)) continue
e.preventDefault()
e.stopPropagation()
try {
cmd.handler(e)
} catch (err) {
logger.error('Global command handler threw', { id: cmd.id, err })
}
return
}
}
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [isMac, router])
const invoke = useCallback((id: string): boolean => {
const cmd = registryRef.current.get(id)
if (!cmd) return false
try {
cmd.handler(new KeyboardEvent('keydown'))
} catch (err) {
logger.error('Global command handler threw', { id, err })
}
return true
}, [])
const value = useMemo<GlobalCommandsContextValue>(
() => ({ register, invoke }),
[register, invoke]
)
return <GlobalCommandsContext.Provider value={value}>{children}</GlobalCommandsContext.Provider>
}
/**
* Returns a function that runs a registered global command by id, mirroring its
* keyboard shortcut exactly. Returns `false` when no command with that id is
* currently registered (e.g. a workflow-only command invoked off-canvas), so
* callers can offer the action safely without knowing what is mounted.
*/
export function useInvokeGlobalCommand(): (id: string) => boolean {
const ctx = useContext(GlobalCommandsContext)
if (!ctx) {
throw new Error('useInvokeGlobalCommand must be used within GlobalCommandsProvider')
}
return ctx.invoke
}
export function useRegisterGlobalCommands(commands: GlobalCommand[] | (() => GlobalCommand[])) {
const ctx = useContext(GlobalCommandsContext)
if (!ctx) {
throw new Error('useRegisterGlobalCommands must be used within GlobalCommandsProvider')
}
const commandsRef = useRef<GlobalCommand[]>([])
const list = typeof commands === 'function' ? commands() : commands
commandsRef.current = list
useEffect(() => {
const wrappedCommands = commandsRef.current.map((cmd) => ({
...cmd,
handler: (event: KeyboardEvent) => {
const currentCmd = commandsRef.current.find((c) => c.id === cmd.id)
if (currentCmd) {
currentCmd.handler(event)
}
},
}))
const unregister = ctx.register(wrappedCommands)
return unregister
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}