simian 0.2.1

A command-line tool for exploring and implementing Machine Learning algorithms in Rust.
import { useCallback, useEffect, useMemo, useState } from 'react'
import { DecoratedRange, Path, Transforms } from 'slate'

import { useEditor } from '@/ui/editor/context'
import { voidAddon } from '../base'
import { CommandId, useCommandGroups } from './command'
import { SlashContext } from './context'
import { getSlash } from './utils'
import {
  Slash,
  SlashAddon,
  SlashCommandGroupWithIndex,
  SlashCommandWithIndex,
} from './types'
import { SlashMenu } from './menu'

/**
 * The context provider component.
 */
const ContextProvider: SlashAddon['ContextProvider'] = ({
  addon,
  children,
}) => {
  const { editor } = useEditor()
  const [slash, setSlashBase] = useState<Slash | null>(addon.slash)
  const [cmdIdx, setCmdIdx] = useState(0)
  const allGroups = useCommandGroups()

  const setSlash = useCallback(
    (slash: Slash | null) => {
      Object.assign(addon, { slash })
      setSlashBase(slash)
    },
    [addon, setSlashBase],
  )

  const groups = useMemo(() => {
    let idx = 0

    return allGroups.reduce((groups, group) => {
      if (!slash?.query) {
        return [
          ...groups,
          {
            ...group,
            commands: group.commands.map((cmd) => ({
              ...cmd,
              idx: idx++,
            })),
          },
        ]
      }

      const cmds = group.commands
        .filter((cmd) =>
          cmd.title.toLowerCase().startsWith(slash.query.toLowerCase()),
        )
        .map((cmd) => ({ ...cmd, idx: idx++ }))

      if (!cmds.length) {
        return groups
      }

      return [...groups, { ...group, commands: cmds }]
    }, [] as SlashCommandGroupWithIndex[])
  }, [slash, allGroups])

  const commands = useMemo(
    () => groups.flatMap((group) => group.commands),
    [groups],
  )

  const moveSlashCmd = useCallback(
    (direction: -1 | 1) => {
      setCmdIdx((prev) => {
        return Math.min(commands.length - 1, Math.max(0, prev + direction))
      })
    },
    [commands.length],
  )

  const runSlashCmd = useCallback(
    (aCmd?: SlashCommandWithIndex) => {
      const cmd = aCmd ?? commands.find((cmd) => cmd.idx === cmdIdx)

      if (slash?.range && cmd) {
        Transforms.select(editor, slash.range)
        Transforms.delete(editor)

        cmd.run()
        setSlash(null)
      }
    },
    [editor, slash, commands, cmdIdx, setSlash],
  )

  useEffect(() => {
    // eslint-disable-next-line react-hooks/set-state-in-effect
    setCmdIdx(0)
  }, [commands])

  useEffect(() => {
    if (!slash) {
      // eslint-disable-next-line react-hooks/set-state-in-effect
      setCmdIdx(0)
    }
  }, [slash])

  // Update the addon.
  useEffect(() => {
    Object.assign(addon, {
      setSlash,
      moveSlashCmd,
      runSlashCmd,
    })
  }, [addon, setSlash, moveSlashCmd, runSlashCmd])

  return (
    <SlashContext.Provider
      value={{
        commands,
        cmdIdx,
        groups,
        runSlashCmd,
        slash,
        setCmdIdx,
        setSlash,
      }}
    >
      {children}

      <SlashMenu />
    </SlashContext.Provider>
  )
}

const Companion: SlashAddon['Companion'] = () => {
  const { editor } = useEditor()

  return <div id={`slash-menu-root-${editor.id}`} />
}

/**
 * Handle key down to navigate through slash commands menu.
 */
const onKeyDown: SlashAddon['onKeyDown'] = ({ addon }, evt) => {
  const { slash, setSlash, moveSlashCmd, runSlashCmd } = addon

  if (slash) {
    switch (evt.key) {
      case 'ArrowDown': {
        evt.preventDefault()
        moveSlashCmd?.(1)
        return true
      }
      case 'ArrowUp': {
        evt.preventDefault()
        moveSlashCmd?.(-1)
        return true
      }
      case 'Enter': {
        evt.preventDefault()
        runSlashCmd?.()
        return true
      }
      case ' ': {
        setSlash?.(null)
        break
      }
      case 'Escape': {
        evt.preventDefault()
        setSlash?.(null)
        return true
      }
    }
  }

  return false
}

/**
 * Handle insert text to detect when a slash is typed so we
 * can show the slash menu.
 */
const insertText: SlashAddon['insertText'] = (
  { addon, editor, insertText, selection },
  text,
) => {
  const { slash, setSlash } = addon

  // Keep growing the current slash.
  if (slash) {
    const slash = getSlash(editor, text)

    if (slash) {
      insertText(text)
      setSlash?.(slash)
      return true // break following execution
    } else {
      setSlash?.(null)
    }
  }

  if (text === '/' && selection?.isCollapsed) {
    const slash = getSlash(editor, text)

    if (slash) {
      insertText(text)
      setSlash?.(slash)
      return true
    }
  }

  return false
}

/**
 * Handle delete backward to close the slash menu.
 */
const deleteBackward: SlashAddon['deleteBackward'] = (
  { addon, editor, deleteBackward },
  unit,
) => {
  if (addon.slash) {
    // Checks if we remove the slash.
    const slash = getSlash(editor, '', true)

    if (slash) {
      addon.setSlash?.(slash)
    } else {
      addon.setSlash?.(null)
    }

    deleteBackward(unit)
    return true // Break following execution
  }

  return false
}

/**
 * Decorate the slash range so text addon can style it.
 */
const decorate: SlashAddon['decorate'] = ({ addon }, [, path]) => {
  const ranges: DecoratedRange[] = []

  if (addon.slash?.range) {
    const { range } = addon.slash

    if (Path.equals(path, range.anchor.path)) {
      ranges.push({
        ...range,
        slash: true,
      })
    }
  }

  return ranges
}

/**
 * The addon builder.
 */
export function slash(opts?: { commandIds?: CommandId[] }): SlashAddon {
  return voidAddon({
    id: 'slash',
    commandIds: opts?.commandIds,
    decorate,
    deleteBackward,
    insertText,
    slash: null,
    setSlash: () => {},
    onKeyDown,

    Companion,
    ContextProvider,
  })
}

export * from './types'