diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 43006c81153ec..9f03774f2acee 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -2,13 +2,12 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { useEffect, - useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode, } from "react"; -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useLocation, useRouter, type AnyRouter } from "@tanstack/react-router"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { isElectron } from "../env"; @@ -62,7 +61,9 @@ import { useSidebarVisibility, } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { useThreadRouteRef } from "../hooks/useThreadRouteTarget"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { registerCommands, registerMenuAction } from "../shortcuts/dispatcher"; +import { resolveThreadRouteRef } from "../threadRoutes"; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "var(--desktop-window-controls-inset, 90px)"; @@ -124,7 +125,144 @@ function readInitialThreadSidebarWidth(): number { } } +function readUsagePageOpen(router: AnyRouter) { + return router.state.location.pathname === "/usage"; +} + +function isKeybindingCaptureTarget(event: KeyboardEvent) { + return event.target instanceof HTMLElement && event.target.closest("[data-keybinding-capture]"); +} + +function readRoutePanelsOpen(router: AnyRouter) { + const routeThreadRef = resolveThreadRouteRef(router.state.matches.at(-1)?.params ?? {}); + if (!routeThreadRef) return { terminalOpen: false, previewOpen: false }; + return { + terminalOpen: selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + routeThreadRef, + ).terminalOpen, + previewOpen: + selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) === + "preview", + }; +} + +export function registerNavigationHistoryCommands(router: AnyRouter): () => void { + return registerCommands( + { + "navigation.back": (event) => { + event.preventDefault(); + event.stopPropagation(); + window.history.back(); + }, + "navigation.forward": (event) => { + event.preventDefault(); + event.stopPropagation(); + window.history.forward(); + }, + }, + { + when: (event) => !isKeybindingCaptureTarget(event), + context: () => ({ + ...readRoutePanelsOpen(router), + usagePageOpen: readUsagePageOpen(router), + }), + }, + ); +} + +const MODIFIER_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]); + +// A held key undoes once. Module state: undoing an archive navigates, and the +// hold must survive whatever that re-renders. +let heldUndoKey: string | null = null; + +// Undoes the thread actions in the sidebar's notice. Settings and Usage own +// mod+z while open. Captured so mod+z reaches it before the composer's own +// undo handler consumes the key. +function registerThreadUndoCommands( + router: AnyRouter, + readSidebarVisible: () => boolean, +): () => void { + const threadSidebarShown = () => !isAppModalPage(router.state.location.pathname); + const unregister = registerCommands( + {}, + { + capture: true, + when: threadSidebarShown, + onKeyDown: (event) => { + // Cancel a held key's repeats so they cannot fall through to the + // editor's own undo, even after the last notice is gone. + if (event.repeat) { + if (event.code !== heldUndoKey) return false; + event.preventDefault(); + event.stopPropagation(); + return true; + } + // A modifier tapped mid-hold leaves the held key repeating. + if (!MODIFIER_KEYS.has(event.key)) heldUndoKey = null; + const command = resolveShortcutCommand( + event, + appAtomRegistry.get(primaryServerKeybindingsAtom), + { + context: { + terminalFocus: isTerminalFocused(), + previewFocus: isPreviewFocused(), + ...readRoutePanelsOpen(router), + // A collapsed sidebar hides the notice, so text fields keep their own undo. + editableFocus: + isEditableFocused(event.target) && + !(readSidebarVisible() && threadUndoClaimsShortcut()), + modelPickerOpen: isModelPickerOpen(), + }, + }, + ); + if (command !== "thread.undo") { + if (!MODIFIER_KEYS.has(event.key)) releaseThreadUndoShortcut(); + return false; + } + if (isCommandPaletteOpen() || isModelPickerOpen()) return false; + if (!undoLatestThreadAction()) return false; + heldUndoKey = event.code; + event.preventDefault(); + event.stopPropagation(); + return true; + }, + }, + ); + // Pastes from a menu, dictation and drops edit text without a keypress. + const onInput = () => { + if (threadSidebarShown()) releaseThreadUndoShortcut(); + }; + window.addEventListener("input", onInput, true); + return () => { + unregister(); + window.removeEventListener("input", onInput, true); + }; +} + +/** Keyboard and menu commands that live as long as the app shell. */ +function registerAppShellCommands(router: AnyRouter, shellElement: Element): () => void { + // The provider mirrors the visibility it renders onto its wrapper, so the + // committed state is readable when a key arrives. + const readSidebarVisible = () => + shellElement.closest('[data-slot="sidebar-wrapper"]')?.getAttribute("data-sidebar-state") === + "expanded"; + const disposers = [ + registerThreadUndoCommands(router, readSidebarVisible), + registerNavigationHistoryCommands(router), + registerMenuAction("open-settings", () => { + if (!/^\/settings(\/|$)/.test(router.state.location.pathname)) + void router.navigate({ to: "/settings" }); + }), + ]; + return () => { + for (const dispose of disposers) dispose(); + }; +} + function SidebarControl() { + const router = useRouter(); const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { toggleSidebar, isMobile, mobileSheetMounted } = useSidebar(); @@ -176,8 +314,16 @@ function SidebarControl() { }; }, [keybindings, toggleSidebar, usagePageOpen]); + // The control lives as long as the shell, so it carries the shell's commands. Created + // once so re-renders never re-register them. + const [appShellCommandsRef] = useState( + () => (node: HTMLDivElement | null) => + node === null ? undefined : registerAppShellCommands(router, node), + ); + return (
) { - if (!routeThreadRef) return { terminalOpen: false, previewOpen: false }; - return { - terminalOpen: selectThreadTerminalUiState( - useTerminalUiStateStore.getState().terminalUiStateByThreadKey, - routeThreadRef, - ).terminalOpen, - previewOpen: - selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) === - "preview", - }; -} - -export function NavigationHistoryShortcuts() { - const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" }); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const routeThreadRef = useThreadRouteRef(); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented) return; - if ( - event.target instanceof HTMLElement && - event.target.closest("[data-keybinding-capture]") - ) { - return; - } - const command = resolveShortcutCommand(event, keybindings, { - context: { - terminalFocus: isTerminalFocused(), - previewFocus: isPreviewFocused(), - ...routePanelsOpen(routeThreadRef), - editableFocus: isEditableFocused(event.target), - modelPickerOpen: isModelPickerOpen(), - usagePageOpen, - }, - }); - if (command !== "navigation.back" && command !== "navigation.forward") return; - - event.preventDefault(); - event.stopPropagation(); - if (command === "navigation.back") window.history.back(); - else window.history.forward(); - }; - - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, routeThreadRef, usagePageOpen]); - - return null; -} - -const MODIFIER_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]); - -// Undoes the thread actions in the sidebar's notice. Settings and Usage own -// mod+z while open. Listens in the capture phase -// so mod+z reaches it before the composer's own undo handler consumes the key. -function ThreadUndoShortcut() { - const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const routeThreadRef = useThreadRouteRef(); - // A collapsed sidebar hides the notice, so text fields keep their own undo. - const noticeVisible = useSidebarVisibility(); - // Outlives the effect: undoing an archive navigates, which re-runs it mid-hold. - const heldUndoKey = useRef(null); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - // A held key undoes once. Cancel its repeats so they cannot fall through - // to the editor's own undo, even after the last notice is gone. - if (event.repeat) { - if (event.code === heldUndoKey.current) { - event.preventDefault(); - event.stopPropagation(); - } - return; - } - // A modifier tapped mid-hold leaves the held key repeating. - if (!MODIFIER_KEYS.has(event.key)) heldUndoKey.current = null; - const command = resolveShortcutCommand(event, keybindings, { - context: { - terminalFocus: isTerminalFocused(), - previewFocus: isPreviewFocused(), - ...routePanelsOpen(routeThreadRef), - editableFocus: - isEditableFocused(event.target) && !(noticeVisible && threadUndoClaimsShortcut()), - modelPickerOpen: isModelPickerOpen(), - }, - }); - if (command !== "thread.undo") { - if (!MODIFIER_KEYS.has(event.key)) releaseThreadUndoShortcut(); - return; - } - if (isCommandPaletteOpen() || isModelPickerOpen()) return; - if (undoLatestThreadAction()) { - heldUndoKey.current = event.code; - event.preventDefault(); - event.stopPropagation(); - } - }; - // Pastes from a menu, dictation and drops edit text without a keypress. - const onInput = () => releaseThreadUndoShortcut(); - - window.addEventListener("keydown", onKeyDown, true); - window.addEventListener("input", onInput, true); - return () => { - window.removeEventListener("keydown", onKeyDown, true); - window.removeEventListener("input", onInput, true); - }; - }, [keybindings, noticeVisible, routeThreadRef]); - - return null; -} - export function AppSidebarLayout({ children }: { children: ReactNode }) { - const navigate = useNavigate(); const legacySidebarEnabled = useLegacySidebarEnabled(); const [sidebarRouteReveal] = useState(() => ({ observedKey: null, @@ -331,7 +363,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const pathname = useLocation({ select: (location) => location.pathname }); const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname); const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed; - const appModalOpen = isAppModalPage(pathname); const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); // A clamped drag may not change width, so viewport changes need a separate subscription. @@ -362,26 +393,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { ...windowControlsInsetStyle, } as CSSProperties; - useEffect(() => { - const onMenuAction = window.desktopBridge?.onMenuAction; - if (typeof onMenuAction !== "function") { - return; - } - - const unsubscribe = onMenuAction((action) => { - if (action === "open-settings") { - const isSettingsRoute = /^\/settings(\/|$)/.test(pathname); - if (!isSettingsRoute) { - void navigate({ to: "/settings" }); - } - } - }); - - return () => { - unsubscribe?.(); - }; - }, [navigate, pathname]); - return ( - - {appModalOpen ? null : } diff --git a/apps/web/src/components/UsageToggleShortcut.browser.test.tsx b/apps/web/src/components/UsageToggleShortcut.browser.test.tsx index 1efaa2837b008..a93990dccd435 100644 --- a/apps/web/src/components/UsageToggleShortcut.browser.test.tsx +++ b/apps/web/src/components/UsageToggleShortcut.browser.test.tsx @@ -16,12 +16,23 @@ vi.mock(import("@effect/atom-react"), async (original) => ({ ...(await original()), useAtomValue: (() => keybindings) as never, })); +vi.mock(import("../state/server"), async (original) => { + const actual = await original(); + const { Atom } = await import("effect/reactivity"); + const { compileResolvedKeybindingsConfig: compile } = await import("@t3tools/shared/keybindings"); + // Factories are hoisted above the module's own constants. + const compiled = compile([ + { key: "mod+u", command: "navigation.back", when: "usagePageOpen" }, + { key: "mod+u", command: "usage.open", when: "!usagePageOpen" }, + ]); + return { ...actual, primaryServerKeybindingsAtom: Atom.make(compiled) }; +}); vi.mock(import("@tanstack/react-router"), async (original) => ({ ...(await original()), useNavigate: (() => state.navigate) as never, })); -import { NavigationHistoryShortcuts } from "./AppSidebarLayout"; +import { registerNavigationHistoryCommands } from "./AppSidebarLayout"; import { CommandPalette } from "./CommandPalette"; // regression: #21357 @@ -29,7 +40,8 @@ describe("usage toggle shortcut on Usage", () => { it("goes back", async () => { vi.clearAllMocks(); using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); - await using _screen = await render(, { router: "/usage" }); + await using screen = await render(null, { router: "/usage" }); + using _commands = { [Symbol.dispose]: registerNavigationHistoryCommands(screen.router!) }; using back = vi.spyOn(window.history, "back").mockImplementation(() => undefined); await userEvent.keyboard("{Control>}u{/Control}"); diff --git a/apps/web/src/shortcuts/dispatcher.browser.test.ts b/apps/web/src/shortcuts/dispatcher.browser.test.ts new file mode 100644 index 0000000000000..308b26fdf2696 --- /dev/null +++ b/apps/web/src/shortcuts/dispatcher.browser.test.ts @@ -0,0 +1,148 @@ +import type { KeybindingCommand, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + commandScopeRef, + dispatchMenuAction, + registerCommands, + registerMenuAction, +} from "./dispatcher"; + +function bindings(entries: ReadonlyArray<[string, KeybindingCommand]>): ResolvedKeybindingsConfig { + return entries.map(([key, command]) => ({ + command, + shortcut: { + key, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: false, + }, + })); +} + +const keybindings = () => + bindings([ + ["k", "chat.new"], + ["j", "chat.newLocal"], + ]); + +function press(key: string): KeyboardEvent { + const event = new KeyboardEvent("keydown", { key, cancelable: true, bubbles: true }); + window.dispatchEvent(event); + return event; +} + +function mountScope(...args: Parameters): () => void { + return commandScopeRef(...args)(document.body) ?? (() => {}); +} + +describe("command dispatcher", () => { + it("runs the newest scope's handler and nothing below it", () => { + const global = vi.fn(); + const outer = vi.fn(); + const inner = vi.fn(); + using stack = new DisposableStack(); + stack.defer(registerCommands({ "chat.new": global }, { keybindings })); + stack.defer(mountScope({ "chat.new": outer }, { keybindings })); + stack.defer(mountScope({ "chat.new": inner }, { keybindings })); + + press("k"); + + expect(inner).toHaveBeenCalledOnce(); + expect(outer).not.toHaveBeenCalled(); + expect(global).not.toHaveBeenCalled(); + }); + + it("falls through scopes that return false, skip the command, or fail their guard", () => { + const global = vi.fn(); + const declined = vi.fn(() => false); + const guarded = vi.fn(); + using stack = new DisposableStack(); + stack.defer(registerCommands({ "chat.new": global }, { keybindings })); + stack.defer(mountScope({ "chat.new": declined }, { keybindings })); + stack.defer(mountScope({ "chat.newLocal": vi.fn() }, { keybindings })); + stack.defer(mountScope({ "chat.new": guarded }, { keybindings, when: () => false })); + + press("k"); + + expect(guarded).not.toHaveBeenCalled(); + expect(declined).toHaveBeenCalledOnce(); + expect(global).toHaveBeenCalledOnce(); + }); + + it("element scopes win over global commands registered after them", () => { + const element = vi.fn(); + const global = vi.fn(); + using stack = new DisposableStack(); + stack.defer(mountScope({ "chat.new": element }, { keybindings })); + stack.defer(registerCommands({ "chat.new": global }, { keybindings })); + + press("k"); + + expect(element).toHaveBeenCalledOnce(); + expect(global).not.toHaveBeenCalled(); + }); + + it("stops once a handler prevents default, and unregisters on cleanup", () => { + const lower = vi.fn(); + const upper = vi.fn((event: KeyboardEvent) => { + event.preventDefault(); + return false; + }); + using stack = new DisposableStack(); + stack.defer(mountScope({ "chat.new": lower }, { keybindings })); + const unmountUpper = mountScope({ "chat.new": upper }, { keybindings }); + + press("k"); + expect(lower).not.toHaveBeenCalled(); + + unmountUpper(); + press("k"); + expect(upper).toHaveBeenCalledOnce(); + expect(lower).toHaveBeenCalledOnce(); + }); + + it("lets a scope claim raw keys before command lookup", () => { + const onEscape = vi.fn((event: KeyboardEvent) => (event.key === "Escape" ? undefined : false)); + const command = vi.fn(); + using stack = new DisposableStack(); + stack.defer(mountScope({ "chat.new": command }, { keybindings, onKeyDown: onEscape })); + + press("Escape"); + press("k"); + + expect(onEscape).toHaveBeenCalledTimes(2); + expect(command).toHaveBeenCalledOnce(); + }); + + it("tells observers whether a scope consumed the key, after the scopes ran", () => { + const calls: Array = []; + using stack = new DisposableStack(); + stack.defer( + registerCommands({}, { observe: (_event, consumed) => calls.push(`observe:${consumed}`) }), + ); + stack.defer(mountScope({ "chat.new": () => void calls.push("command") }, { keybindings })); + + press("k"); + press("x"); + + expect(calls).toEqual(["command", "observe:true", "observe:false"]); + }); + + it("routes a menu action to its newest handler", () => { + const older = vi.fn(); + const newer = vi.fn(); + using stack = new DisposableStack(); + stack.defer(registerMenuAction("test-action", older)); + const unregisterNewer = registerMenuAction("test-action", newer); + + dispatchMenuAction("test-action"); + unregisterNewer(); + dispatchMenuAction("test-action"); + + expect(newer).toHaveBeenCalledOnce(); + expect(older).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/shortcuts/dispatcher.ts b/apps/web/src/shortcuts/dispatcher.ts new file mode 100644 index 0000000000000..bb5b13a9980bf --- /dev/null +++ b/apps/web/src/shortcuts/dispatcher.ts @@ -0,0 +1,154 @@ +import type { KeybindingCommand, ResolvedKeybindingsConfig } from "@t3tools/contracts"; + +import { resolveShortcutCommand, type ShortcutMatchContext } from "../keybindings"; +import { isEditableFocused } from "../lib/editableFocus"; +import { isPreviewFocused } from "../lib/previewFocus"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { isModelPickerOpen } from "../modelPickerVisibility"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { primaryServerKeybindingsAtom } from "../state/server"; + +/** Return `false` to let the next scope handle the event. */ +export type CommandHandler = (event: KeyboardEvent) => boolean | void; + +export type CommandHandlers = Partial>; + +export interface CommandScopeOptions { + /** Read at event time; a scope whose guard fails is skipped. */ + readonly when?: (event: KeyboardEvent) => boolean; + /** When-clause context the dispatcher cannot know (terminalOpen, previewOpen), read at event time. */ + readonly context?: () => Partial; + readonly keybindings?: () => ResolvedKeybindingsConfig; + /** For shortcuts that are not keybinding commands (Escape, "/"); runs before command lookup. */ + readonly onKeyDown?: CommandHandler; + /** + * App-global only: run in the capture phase, ahead of focused widgets that consume the key + * themselves (the terminal encodes Ctrl+Shift+T and stops propagation). + */ + readonly capture?: boolean; + /** + * App-global only: hear every bubbling keydown after the scopes ran, with whether one of them + * (or the page) consumed it. Observers never consume. For listeners that react to keys other + * scopes may also handle, such as scroll keys. + */ + readonly observe?: (event: KeyboardEvent, consumed: boolean) => void; +} + +interface CommandScope { + readonly commands: CommandHandlers; + readonly options: CommandScopeOptions; +} + +// Element scopes sit above app-global scopes; within each list the newest wins. +const elementScopes: CommandScope[] = []; +const globalScopes: CommandScope[] = []; +const captureScopes: CommandScope[] = []; +const observerScopes: CommandScope[] = []; + +function readFocusContext(event: KeyboardEvent): Partial { + return { + terminalFocus: isTerminalFocused(), + previewFocus: isPreviewFocused(), + editableFocus: isEditableFocused(event.target), + modelPickerOpen: isModelPickerOpen(), + }; +} + +function runScope(scope: CommandScope, event: KeyboardEvent): boolean { + const { commands, options } = scope; + if (options.when && !options.when(event)) return false; + if (options.onKeyDown && options.onKeyDown(event) !== false) return true; + const command = resolveShortcutCommand( + event, + options.keybindings?.() ?? appAtomRegistry.get(primaryServerKeybindingsAtom), + { context: { ...readFocusContext(event), ...options.context?.() } }, + ); + const handler = command === null ? undefined : commands[command]; + return handler !== undefined && handler(event) !== false; +} + +function runScopes(scopes: readonly CommandScope[], event: KeyboardEvent): boolean { + for (let index = scopes.length - 1; index >= 0; index -= 1) { + if (event.defaultPrevented) return true; + const scope = scopes[index]; + if (scope && runScope(scope, event)) return true; + } + return false; +} + +function dispatchCaptureKeyDown(event: KeyboardEvent): void { + runScopes(captureScopes, event); +} + +function dispatchKeyDown(event: KeyboardEvent): void { + const consumed = + runScopes(elementScopes, event) || runScopes(globalScopes, event) || event.defaultPrevented; + for (const scope of observerScopes.slice()) scope.options.observe?.(event, consumed); +} + +let keydownInstalled = false; +function ensureKeydownListener() { + if (keydownInstalled || typeof window === "undefined") return; + keydownInstalled = true; + window.addEventListener("keydown", dispatchCaptureKeyDown, true); + window.addEventListener("keydown", dispatchKeyDown); +} + +function pushScope(list: CommandScope[], scope: CommandScope): () => void { + ensureKeydownListener(); + list.push(scope); + return () => { + const index = list.lastIndexOf(scope); + if (index !== -1) list.splice(index, 1); + }; +} + +/** App-global commands, registered once at module level or bootstrap. */ +export function registerCommands( + commands: CommandHandlers, + options: CommandScopeOptions = {}, +): () => void { + const scopes = options.observe ? observerScopes : options.capture ? captureScopes : globalScopes; + return pushScope(scopes, { commands, options }); +} + +/** + * Ref callback that keeps a command scope registered while its element is + * mounted. Handlers should read changing values from stores at event time so + * the callback stays stable across renders. + */ +export function commandScopeRef( + commands: CommandHandlers, + options: CommandScopeOptions = {}, +): (node: Element | null) => (() => void) | undefined { + return (node) => (node === null ? undefined : pushScope(elementScopes, { commands, options })); +} + +type MenuActionHandler = () => void; +const menuActionHandlers = new Map(); +let menuActionInstalled = false; + +export function dispatchMenuAction(action: string): void { + const handlers = menuActionHandlers.get(action); + handlers?.at(-1)?.(); +} + +function ensureMenuActionListener() { + if (menuActionInstalled || typeof window === "undefined") return; + const bridge = window.desktopBridge; + if (typeof bridge?.onMenuAction !== "function") return; + menuActionInstalled = true; + bridge.onMenuAction(dispatchMenuAction); +} + +/** Desktop menu actions; the newest handler for an action wins. */ +export function registerMenuAction(action: string, handler: MenuActionHandler): () => void { + ensureMenuActionListener(); + const handlers = menuActionHandlers.get(action) ?? []; + handlers.push(handler); + menuActionHandlers.set(action, handlers); + return () => { + const index = handlers.lastIndexOf(handler); + if (index !== -1) handlers.splice(index, 1); + }; +}