go.charczuk.com@v0.0.0-20240327042549-bc490516bd1a/projects/nodes/static/_nextjs/src/components/omnibar.tsx (about) 1 /** 2 * Copyright (c) 2024 - Present. Will Charczuk. All rights reserved. 3 * Use of this source code is governed by a MIT license that can be found in the LICENSE file at the root of the repository. 4 */ 5 import { HotkeysTarget2, IconName, Intent, MenuItem } from "@blueprintjs/core" 6 import { Node as FlowNode, XYPosition } from 'reactflow'; 7 import { Omnibar as BP5Omnibar, ItemPredicate, ItemRenderer, ItemRendererProps } from "@blueprintjs/select" 8 import { useEffect, useState } from "react" 9 import { NodeData } from "../store/nodeData" 10 import { nodeTypesWithName } from "../refdata/nodeTypes"; 11 import { currentOS } from "./utils"; 12 13 declare interface OmnibarProps { 14 nodes: FlowNode<NodeData>[] 15 16 isOpen?: boolean; 17 onClose?: () => void; 18 19 onAdd: (nodeType: string | null, position?: XYPosition) => void; 20 onStabilize: () => void; 21 onRefresh: () => void; 22 onSave: () => void; 23 onLoad: () => void; 24 onLink: () => void; 25 onCollapseAll: (collapsed: boolean) => void; 26 onCollapse: (node: FlowNode<NodeData>, collapsed: boolean) => void; 27 onEdit: (node: FlowNode<NodeData>) => void; 28 onRemove: (node: FlowNode<NodeData>) => void; 29 onRemoveSelected: () => void; 30 onObserve: (node: FlowNode<NodeData>) => void; 31 onDuplicate: (node: FlowNode<NodeData>) => void; 32 onDuplicateSelected: () => void; 33 onSetStale: (node: FlowNode<NodeData>) => void; 34 35 onShowGraphLogs: () => void; 36 } 37 38 declare interface OmnibarItem { 39 key: string; 40 text: string; 41 icon?: IconName; 42 intent?: Intent; 43 action: () => void 44 } 45 46 export const Omnibar = (props: OmnibarProps) => { 47 const os = currentOS(); 48 49 const [isOpen, setIsOpen] = useState<boolean>(false); 50 51 useEffect(() => { 52 if (props.isOpen !== undefined) { 53 setIsOpen(props.isOpen || isOpen) 54 } 55 }, [props.isOpen]) 56 57 const getLabel = (fn: FlowNode<NodeData>): string => { 58 return fn.data.node.label 59 } 60 61 props.nodes.sort((a, b) => { 62 let aLabel = getLabel(a) 63 let bLabel = getLabel(b); 64 if (aLabel < bLabel) { return -1 } 65 if (aLabel > bLabel) { return 1 } 66 return 0 67 }) 68 69 const omnibarItems: Array<OmnibarItem> = [ 70 { text: "Add", key: 'add_node', icon: 'add', action: () => { props.onAdd(null) } }, 71 { text: "Stabilize", key: 'stabilize', icon: 'refresh', intent: Intent.PRIMARY, action: props.onStabilize }, 72 { text: "Refresh", key: 'refresh', icon: 'refresh', action: props.onRefresh }, 73 { text: "Save", key: 'save', icon: 'cloud-download', action: props.onSave }, 74 { text: "Load", key: 'load', icon: 'cloud-upload', action: props.onLoad }, 75 { text: "Logs", key: 'logs', icon: 'application', action: props.onShowGraphLogs }, 76 { text: "Link", key: 'link', icon: 'new-link', intent: Intent.SUCCESS, action: props.onLink }, 77 { text: "Collapse All", key: 'collapse_all', icon: 'collapse-all', action: () => { props.onCollapseAll(true) } }, 78 { text: "Expand All", key: 'expand_all', icon: 'expand-all', action: () => { props.onCollapseAll(false) } }, 79 80 { text: 'Duplicate Selected', key: 'duplicate_selected', icon: 'duplicate', action: props.onDuplicateSelected }, 81 { text: 'Remove Selected', key: 'remove_selected', icon: 'delete', intent: Intent.DANGER, action: props.onRemoveSelected }, 82 ...nodeTypesWithName.map(nt => { return { text: `Add > ${nt.name}`, key: `add_node_${nt.name}`, icon: 'add' as IconName, intent: Intent.SUCCESS, action: () => props.onAdd(nt.name) } }), 83 ...props.nodes.map(n => { return { text: `Edit > ${getLabel(n)}`, key: `edit_${n.id}`, icon: 'edit' as IconName, action: () => props.onEdit(n) } }), 84 ...props.nodes.map(n => { return { text: `Remove > ${getLabel(n)}`, key: `remove_${n.id}`, icon: 'trash' as IconName, action: () => props.onRemove(n) } }), 85 ...props.nodes.map(n => { return { text: `Duplicate > ${getLabel(n)}`, key: `duplicate_${n.id}`, icon: 'duplicate' as IconName, action: () => props.onDuplicate(n) } }), 86 ...props.nodes.map(n => { return { text: `Observe > ${getLabel(n)}`, key: `observe_${n.id}`, icon: 'eye-open' as IconName, action: () => props.onObserve(n) } }), 87 ...props.nodes.map(n => { return { text: `Stale > ${getLabel(n)}`, key: `set_stale_${n.id}`, icon: 'outdated' as IconName, action: () => props.onSetStale(n) } }), 88 ...props.nodes.map(n => { return { text: `Collapse > ${getLabel(n)}`, key: `collapse_${n.id}`, icon: 'collapse-all' as IconName, action: () => props.onCollapse(n, true) } }), 89 ...props.nodes.map(n => { return { text: `Expand > ${getLabel(n)}`, key: `expand_${n.id}`, icon: 'expand-all' as IconName, action: () => props.onCollapse(n, false) } }), 90 ]; 91 92 const openOmnibar = () => { 93 setIsOpen(true) 94 } 95 const closeOmnibar = () => { 96 if (props.onClose) { 97 props.onClose() 98 } 99 setIsOpen(false) 100 } 101 102 const handleItemSelect = (item: OmnibarItem) => { 103 setIsOpen(false); 104 return item.action() 105 } 106 107 const renderItem: ItemRenderer<OmnibarItem> = (item: OmnibarItem, itemProps: ItemRendererProps) => { 108 if (!itemProps.modifiers.matchesPredicate) { 109 return null; 110 } 111 return <MenuItem 112 key={item.key} 113 icon={item.icon} 114 tabIndex={0} 115 intent={item.intent} 116 text={highlightText(item.text, itemProps.query)} 117 roleStructure="listoption" 118 shouldDismissPopover={true} 119 active={itemProps.modifiers.active} 120 disabled={itemProps.modifiers.disabled} 121 onFocus={itemProps.handleFocus} 122 onClick={itemProps.handleClick} 123 />; 124 }; 125 126 const areItemsEqual = (a: OmnibarItem, b: OmnibarItem) => { 127 return a.key === b.key 128 } 129 130 const filterItem: ItemPredicate<OmnibarItem> = (query, film, _index, exactMatch) => { 131 const normalizedTitle = film.text.toLowerCase(); 132 const normalizedQuery = query.toLowerCase(); 133 134 if (exactMatch) { 135 return normalizedTitle === normalizedQuery; 136 } else { 137 return normalizedTitle.indexOf(normalizedQuery) >= 0; 138 } 139 }; 140 141 return ( 142 <HotkeysTarget2 143 hotkeys={[ 144 { 145 combo: os === "Windows" ? "alt + k" : "command + k", 146 global: true, 147 label: "Show Omnibar", 148 onKeyDown: openOmnibar, 149 preventDefault: true, 150 }, 151 ]} 152 ><BP5Omnibar<OmnibarItem> 153 isOpen={isOpen} 154 itemPredicate={filterItem} 155 itemRenderer={renderItem} 156 items={omnibarItems} 157 itemsEqual={areItemsEqual} 158 noResults={<MenuItem disabled={true} text="No results." />} 159 onClose={closeOmnibar} 160 onItemSelect={handleItemSelect} 161 scrollToActiveItem={true} 162 resetOnSelect={true} 163 /></HotkeysTarget2> 164 ) 165 } 166 167 function highlightText(text: string, query: string) { 168 let lastIndex = 0; 169 const words = query 170 .split(/\s+/) 171 .filter(word => word.length > 0) 172 .map(escapeRegExpChars); 173 if (words.length === 0) { 174 return [text]; 175 } 176 const regexp = new RegExp(words.join("|"), "gi"); 177 const tokens: React.ReactNode[] = []; 178 while (true) { 179 const match = regexp.exec(text); 180 if (!match) { 181 break; 182 } 183 const length = match[0].length; 184 const before = text.slice(lastIndex, regexp.lastIndex - length); 185 if (before.length > 0) { 186 tokens.push(before); 187 } 188 lastIndex = regexp.lastIndex; 189 tokens.push(<strong key={lastIndex}>{match[0]}</strong>); 190 } 191 const rest = text.slice(lastIndex); 192 if (rest.length > 0) { 193 tokens.push(rest); 194 } 195 return tokens; 196 } 197 198 function escapeRegExpChars(text: string) { 199 return text.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); 200 }