/** @jsxImportSource preact */ import {Widget} from '@deck.gl/core'; import {render} from 'preact'; import {useCallback, useEffect, useRef, useState} from 'preact/hooks'; import type {Deck, Viewport, WidgetPlacement, WidgetProps} from '@deck.gl/core'; import type {ComponentChildren, JSX} from 'preact'; const OPTION_ROW_HEIGHT_PX = 32; const MAX_VISIBLE_OPTION_COUNT = 4; const BLUR_CLOSE_DELAY_MS = 100; const OMNIBOX_MAX_WIDTH_PX = 520; const OMNIBOX_HORIZONTAL_MARGIN_PX = 12; const FALLBACK_WIDGET_MARGIN_PX = 8; const ROOT_STYLE: Partial = { position: 'fixed', transform: 'translateX(-50%)', margin: '0', zIndex: '2', pointerEvents: 'auto' }; const WRAPPER_STYLE: JSX.CSSProperties = { width: '100%', display: 'flex', flexDirection: 'column', gap: '4px' }; const INPUT_ROW_STYLE: JSX.CSSProperties = { width: '100%', display: 'grid', gridTemplateColumns: '1fr auto auto auto', gap: '4px' }; const INPUT_STYLE: JSX.CSSProperties = { width: '100%', minHeight: '38px', maxHeight: '38px', borderRadius: '8px', border: '1px solid rgba(148, 163, 184, 0.9)', backgroundColor: 'rgba(255, 255, 255, 0.96)', color: 'rgba(15, 23, 42, 1)', boxSizing: 'border-box', padding: '0 12px', fontSize: '13px', lineHeight: 1.2, outline: 'none', boxShadow: '0 4px 16px rgba(15, 23, 42, 0.15)' }; const NAV_BUTTON_STYLE: JSX.CSSProperties = { minWidth: '34px', height: '38px', borderRadius: '8px', border: '1px solid rgba(148, 163, 184, 0.9)', backgroundColor: 'rgba(255, 255, 255, 0.96)', color: 'rgba(15, 23, 42, 1)', boxShadow: '0 4px 16px rgba(15, 23, 42, 0.15)', fontSize: '13px', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }; const NAV_BUTTON_DISABLED_STYLE: JSX.CSSProperties = { opacity: 0.45, cursor: 'not-allowed' }; const DROPDOWN_STYLE: JSX.CSSProperties = { borderRadius: '8px', border: '1px solid rgba(148, 163, 184, 0.9)', backgroundColor: 'rgba(255, 255, 255, 0.98)', boxShadow: '0 12px 28px rgba(15, 23, 42, 0.18)', overflowY: 'auto', maxHeight: `${OPTION_ROW_HEIGHT_PX * MAX_VISIBLE_OPTION_COUNT}px` }; const DEFAULT_OPTION_CONTENT_STYLE: JSX.CSSProperties = { width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px' }; export type OmniBoxOption = { id: string; label: string; value?: string; description?: string; data?: unknown; }; export type OmniBoxOptionProvider = | ((query: string) => Promise>) | ((query: string) => ReadonlyArray); export type OmniBoxRenderOptionArgs = { option: OmniBoxOption; index: number; isActive: boolean; query: string; }; export type OmniBoxWidgetProps = WidgetProps & { placement?: WidgetPlacement; placeholder?: string; minQueryLength?: number; defaultOpen?: boolean; topOffsetPx?: number; getOptions?: OmniBoxOptionProvider; renderOption?: (args: OmniBoxRenderOptionArgs) => ComponentChildren; onSelectOption?: (option: OmniBoxOption) => void; onActiveOptionChange?: (option: OmniBoxOption | null) => void; onNavigateOption?: (option: OmniBoxOption) => void; onQueryChange?: (query: string) => void; }; export class OmniBoxWidget extends Widget { static override defaultProps = { ...Widget.defaultProps, id: 'omni-box', placement: 'top-left', placeholder: 'Search trace blocks…', minQueryLength: 1, defaultOpen: false, topOffsetPx: undefined, getOptions: (() => []) as OmniBoxOptionProvider, renderOption: undefined, onSelectOption: undefined, onActiveOptionChange: undefined, onNavigateOption: undefined, onQueryChange: undefined } satisfies Required & Required> & OmniBoxWidgetProps; placement: WidgetPlacement = OmniBoxWidget.defaultProps.placement; className = 'deck-widget-omni-box'; #rootElement: HTMLElement | null = null; #hasLayoutListeners = false; #handleWindowLayoutChange = () => { this.#updateRootLayout(); }; constructor(props: OmniBoxWidgetProps = {}) { super({...OmniBoxWidget.defaultProps, ...props}); if (props.placement !== undefined) { this.placement = props.placement; } } override setProps(props: Partial): void { if (props.placement !== undefined) { this.placement = props.placement; } super.setProps(props); } override onRenderHTML(rootElement: HTMLElement): void { this.#rootElement = rootElement; rootElement.className = ['deck-widget', this.className, this.props.className] .filter(Boolean) .join(' '); Object.assign(rootElement.style, ROOT_STYLE); this.#attachLayoutListeners(); this.#updateRootLayout(); render( , rootElement ); } override onViewportChange(_viewport: Viewport): void { this.#updateRootLayout(); } override onRemove(): void { this.#detachLayoutListeners(); if (this.#rootElement) { render(null, this.#rootElement); } } #attachLayoutListeners(): void { if (this.#hasLayoutListeners || typeof window === 'undefined') { return; } window.addEventListener('resize', this.#handleWindowLayoutChange); window.addEventListener('scroll', this.#handleWindowLayoutChange, true); this.#hasLayoutListeners = true; } #detachLayoutListeners(): void { if (!this.#hasLayoutListeners || typeof window === 'undefined') { return; } window.removeEventListener('resize', this.#handleWindowLayoutChange); window.removeEventListener('scroll', this.#handleWindowLayoutChange, true); this.#hasLayoutListeners = false; } #updateRootLayout(): void { if (!this.#rootElement || typeof window === 'undefined') { return; } const fallbackTopOffsetPx = getWidgetMarginPx(this.#rootElement); const configuredTopOffsetPx = this.props.topOffsetPx; const topOffsetPx = configuredTopOffsetPx !== undefined && Number.isFinite(configuredTopOffsetPx) ? configuredTopOffsetPx : fallbackTopOffsetPx; const canvasRect = getDeckCanvasRect(this.deck); if (canvasRect) { const availableWidthPx = Math.max(0, canvasRect.width - OMNIBOX_HORIZONTAL_MARGIN_PX * 2); const resolvedWidthPx = availableWidthPx > 0 ? Math.min(OMNIBOX_MAX_WIDTH_PX, availableWidthPx) : OMNIBOX_MAX_WIDTH_PX; this.#rootElement.style.left = `${canvasRect.left + canvasRect.width / 2}px`; this.#rootElement.style.top = `${canvasRect.top + topOffsetPx}px`; this.#rootElement.style.width = `${resolvedWidthPx}px`; return; } this.#rootElement.style.left = '50%'; this.#rootElement.style.top = `${topOffsetPx}px`; this.#rootElement.style.width = `min(${OMNIBOX_MAX_WIDTH_PX}px, calc(100vw - ${OMNIBOX_HORIZONTAL_MARGIN_PX * 2}px))`; } } type OmniBoxWidgetViewProps = { placeholder: string; minQueryLength: number; defaultOpen: boolean; getOptions: OmniBoxOptionProvider; renderOption?: (args: OmniBoxRenderOptionArgs) => ComponentChildren; onSelectOption?: (option: OmniBoxOption) => void; onActiveOptionChange?: (option: OmniBoxOption | null) => void; onNavigateOption?: (option: OmniBoxOption) => void; onQueryChange?: (query: string) => void; }; function DefaultOptionContent({option}: {option: OmniBoxOption}) { return (
{option.label} {option.description && ( {option.description} )}
); } // eslint-disable-next-line max-statements function OmniBoxWidgetView({ placeholder, minQueryLength, defaultOpen, getOptions, renderOption, onSelectOption, onActiveOptionChange, onNavigateOption, onQueryChange }: OmniBoxWidgetViewProps) { const [query, setQuery] = useState(''); const [options, setOptions] = useState>([]); const [activeOptionIndex, setActiveOptionIndex] = useState(-1); const [isLoading, setIsLoading] = useState(false); const [isFocused, setIsFocused] = useState(false); const [isHidden, setIsHidden] = useState(() => !defaultOpen); const inputRef = useRef(null); const dropdownRef = useRef(null); const optionElementRefs = useRef>([]); const requestVersionRef = useRef(0); const blurTimeoutRef = useRef(null); const clearBlurTimeout = useCallback(() => { if (blurTimeoutRef.current !== null) { window.clearTimeout(blurTimeoutRef.current); blurTimeoutRef.current = null; } }, []); useEffect(() => { return () => { clearBlurTimeout(); }; }, [clearBlurTimeout]); useEffect(() => { const handleWindowKeyDown = (event: KeyboardEvent) => { if (event.key !== '/' || event.altKey || event.ctrlKey || event.metaKey) { return; } if (isEditableTarget(event.target)) { return; } event.preventDefault(); stopEventPropagation(event); clearBlurTimeout(); setIsHidden(false); setIsFocused(true); window.requestAnimationFrame(() => { inputRef.current?.focus(); }); }; window.addEventListener('keydown', handleWindowKeyDown, true); return () => { window.removeEventListener('keydown', handleWindowKeyDown, true); }; }, [clearBlurTimeout]); useEffect(() => { setIsHidden(!defaultOpen); }, [defaultOpen]); useEffect(() => { onQueryChange?.(query); const normalizedQuery = query.trim(); if (normalizedQuery.length < minQueryLength) { setOptions([]); setActiveOptionIndex(-1); setIsLoading(false); return; } const requestVersion = requestVersionRef.current + 1; requestVersionRef.current = requestVersion; setIsLoading(true); Promise.resolve(getOptions(normalizedQuery)) .then((nextOptions) => { if (requestVersionRef.current !== requestVersion) { return; } setOptions(nextOptions); setActiveOptionIndex(nextOptions.length > 0 ? 0 : -1); }) .catch(() => { if (requestVersionRef.current !== requestVersion) { return; } setOptions([]); setActiveOptionIndex(-1); }) .finally(() => { if (requestVersionRef.current === requestVersion) { setIsLoading(false); } }); }, [getOptions, minQueryLength, onQueryChange, query]); useEffect(() => { if (activeOptionIndex < 0 || activeOptionIndex >= options.length) { onActiveOptionChange?.(null); return; } onActiveOptionChange?.(options[activeOptionIndex] ?? null); }, [activeOptionIndex, onActiveOptionChange, options]); useEffect(() => { optionElementRefs.current = optionElementRefs.current.slice(0, options.length); }, [options.length]); useEffect(() => { if (!isFocused || activeOptionIndex < 0 || activeOptionIndex >= options.length) { return; } const dropdownElement = dropdownRef.current; const optionElement = optionElementRefs.current[activeOptionIndex]; if (!dropdownElement || !optionElement) { return; } const optionTop = optionElement.offsetTop; const optionBottom = optionTop + optionElement.offsetHeight; const viewportTop = dropdownElement.scrollTop; const viewportBottom = viewportTop + dropdownElement.clientHeight; if (optionTop < viewportTop) { dropdownElement.scrollTop = optionTop; return; } if (optionBottom > viewportBottom) { dropdownElement.scrollTop = optionBottom - dropdownElement.clientHeight; } }, [activeOptionIndex, isFocused, options.length]); const selectOption = useCallback( (option: OmniBoxOption) => { setQuery(option.value ?? option.label); setIsFocused(false); setOptions([]); setActiveOptionIndex(-1); onSelectOption?.(option); }, [onSelectOption] ); const moveActiveOptionBy = useCallback( (delta: -1 | 1, {navigate = false}: {navigate?: boolean} = {}) => { if (!options.length) { return; } const currentIndex = activeOptionIndex >= 0 ? activeOptionIndex : 0; const nextIndex = (currentIndex + delta + options.length) % options.length; const nextOption = options[nextIndex]; if (!nextOption) { return; } setActiveOptionIndex(nextIndex); setIsFocused(true); if (navigate) { onNavigateOption?.(nextOption); } }, [activeOptionIndex, onNavigateOption, options] ); const handleHide = useCallback( (event?: Event) => { if (event) { stopEventPropagation(event); } clearBlurTimeout(); requestVersionRef.current += 1; setQuery(''); setOptions([]); setActiveOptionIndex(-1); setIsLoading(false); setIsFocused(false); setIsHidden(true); }, [clearBlurTimeout] ); const handleInput: JSX.GenericEventHandler = useCallback((event) => { stopEventPropagation(event as unknown as Event); setQuery(event.currentTarget.value); setIsFocused(true); }, []); const handleFocus: JSX.FocusEventHandler = useCallback(() => { clearBlurTimeout(); setIsFocused(true); }, [clearBlurTimeout]); const handleBlur: JSX.FocusEventHandler = useCallback(() => { clearBlurTimeout(); blurTimeoutRef.current = window.setTimeout(() => { setIsFocused(false); setActiveOptionIndex(-1); }, BLUR_CLOSE_DELAY_MS); }, [clearBlurTimeout]); const handleKeyDown: JSX.KeyboardEventHandler = useCallback( (event) => { stopEventPropagation(event as unknown as Event); if (event.key === 'ArrowDown') { event.preventDefault(); moveActiveOptionBy(1); return; } if (event.key === 'ArrowUp') { event.preventDefault(); moveActiveOptionBy(-1); return; } if (event.key === 'Enter') { if (activeOptionIndex >= 0 && activeOptionIndex < options.length) { event.preventDefault(); const option = options[activeOptionIndex]; if (option) { selectOption(option); } } return; } if (event.key === 'Escape') { event.preventDefault(); handleHide(event as unknown as Event); } }, [activeOptionIndex, handleHide, moveActiveOptionBy, options, selectOption] ); const handlePointerEvent: JSX.PointerEventHandler = useCallback((event) => { stopEventPropagation(event as unknown as Event); }, []); const handleMouseEvent: JSX.MouseEventHandler = useCallback((event) => { stopEventPropagation(event as unknown as Event); }, []); const handleWheelEvent: JSX.WheelEventHandler = useCallback((event) => { stopEventPropagation(event as unknown as Event); }, []); const hasMatches = options.length > 0; const normalizedQuery = query.trim(); const shouldShowDropdown = !isHidden && isFocused && normalizedQuery.length >= minQueryLength && (isLoading || options.length > 0); if (isHidden) { return null; } return (
{shouldShowDropdown && (
{isLoading && (
Searching…
)} {!isLoading && options.map((option, index) => { const isActive = index === activeOptionIndex; const content = renderOption?.({ option, index, isActive, query }) ?? ; return ( ); })}
)}
); } function stopEventPropagation(event: Event): void { event.stopPropagation(); if ( typeof (event as {stopImmediatePropagation?: () => void}).stopImmediatePropagation === 'function' ) { (event as {stopImmediatePropagation: () => void}).stopImmediatePropagation(); } } function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof Element)) { return false; } if (target instanceof HTMLInputElement) { return target.type !== 'button' && target.type !== 'checkbox' && target.type !== 'radio'; } if (target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) { return true; } return target instanceof HTMLElement ? target.isContentEditable : false; } function getWidgetMarginPx(element: HTMLElement): number { const value = window.getComputedStyle(element).getPropertyValue('--widget-margin').trim(); const parsed = Number.parseFloat(value); return Number.isFinite(parsed) ? parsed : FALLBACK_WIDGET_MARGIN_PX; } function getDeckCanvasRect(deck: Deck | undefined): DOMRect | null { const canvas = (deck as (Deck & {canvas?: HTMLCanvasElement | null}) | undefined)?.canvas; if (!canvas) { return null; } return canvas.getBoundingClientRect(); }