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  }