diff --git a/apps/roam/src/components/CreateNodeDialog.tsx b/apps/roam/src/components/CreateNodeDialog.tsx deleted file mode 100644 index 4211f7c56..000000000 --- a/apps/roam/src/components/CreateNodeDialog.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import React, { useEffect, useRef, useState } from "react"; -import { Dialog, Classes, InputGroup, Label, Button } from "@blueprintjs/core"; -import renderOverlay from "roamjs-components/util/renderOverlay"; -import createDiscourseNode from "~/utils/createDiscourseNode"; -import { OnloadArgs } from "roamjs-components/types"; -import updateBlock from "roamjs-components/writes/updateBlock"; -import { render as renderToast } from "roamjs-components/components/Toast"; -import getDiscourseNodes, { - DiscourseNode, - excludeDefaultNodes, -} from "~/utils/getDiscourseNodes"; -import { getNewDiscourseNodeText } from "~/utils/formatUtils"; -import MenuItemSelect from "roamjs-components/components/MenuItemSelect"; -import createBlock from "roamjs-components/writes/createBlock"; - -export type CreateNodeDialogProps = { - onClose: () => void; - defaultNodeTypeUid: string; - extensionAPI: OnloadArgs["extensionAPI"]; - sourceBlockUid?: string; - initialTitle: string; -}; - -const CreateNodeDialog = ({ - onClose, - defaultNodeTypeUid, - extensionAPI, - sourceBlockUid, - initialTitle, -}: CreateNodeDialogProps) => { - const discourseNodes = getDiscourseNodes().filter(excludeDefaultNodes); - const defaultNodeType = - discourseNodes.find((n) => n.type === defaultNodeTypeUid) || - discourseNodes[0]; - - const [title, setTitle] = useState(initialTitle); - const [selectedType, setSelectedType] = - useState(defaultNodeType); - const [loading, setLoading] = useState(false); - const inputRef = useRef(null); - - useEffect(() => { - if (inputRef.current) { - inputRef.current.focus(); - } - }, []); - - const onCreate = async () => { - if (!title.trim()) return; - setLoading(true); - - const formattedTitle = await getNewDiscourseNodeText({ - text: title.trim(), - nodeType: selectedType.type, - blockUid: sourceBlockUid, - }); - - if (!formattedTitle) { - setLoading(false); - return; - } - - const newPageUid = await createDiscourseNode({ - text: formattedTitle, - configPageUid: selectedType.type, - extensionAPI, - }); - - if (sourceBlockUid) { - // TODO: This assumes the new node is always a page. If the specification - // defines it as a block (e.g., "is in page with title"), this will not create - // the correct reference. The reference format should be determined by the - // node's specification. - const pageRef = `[[${formattedTitle}]]`; - await updateBlock({ - uid: sourceBlockUid, - text: pageRef, - }); - await createBlock({ - parentUid: sourceBlockUid, - order: 0, - node: { - text: initialTitle, - }, - }); - } - - renderToast({ - id: `discourse-node-created-${Date.now()}`, - intent: "success", - timeout: 10000, - content: ( - - Created node{" "} - { - if (event.shiftKey) { - await window.roamAlphaAPI.ui.rightSidebar.addWindow({ - window: { - // @ts-expect-error TODO: fix this - "block-uid": newPageUid, - type: "outline", - }, - }); - } else { - await window.roamAlphaAPI.ui.mainWindow.openPage({ - page: { uid: newPageUid }, - }); - } - }} - > - [[{formattedTitle}]] - - - ), - }); - setLoading(false); - onClose(); - }; - - return ( - -
-
-
- - setTitle(e.currentTarget.value)} - inputRef={inputRef} - /> -
- - -
-
-
-
- - -
-
-
- ); -}; - -export const renderCreateNodeDialog = (props: CreateNodeDialogProps) => - renderOverlay({ - Overlay: CreateNodeDialog, - props, - }); diff --git a/apps/roam/src/components/FuzzySelectInput.tsx b/apps/roam/src/components/FuzzySelectInput.tsx new file mode 100644 index 000000000..3d8a66295 --- /dev/null +++ b/apps/roam/src/components/FuzzySelectInput.tsx @@ -0,0 +1,223 @@ +import React, { useState, useCallback, useMemo, useRef, useEffect } from "react"; +import { + Button, + TextArea, + InputGroup, + Menu, + MenuItem, + Popover, + PopoverPosition, +} from "@blueprintjs/core"; +import fuzzy from "fuzzy"; +import { Result } from "~/utils/types"; + +type FuzzySelectInputProps = { + value?: T; + setValue: (q: T) => void; + onLockedChange?: (isLocked: boolean) => void; + mode: "create" | "edit"; + initialUid: string; + options?: T[]; + placeholder?: string; + autoFocus?: boolean; + disabled?: boolean; + initialIsLocked?: boolean; +}; + +const FuzzySelectInput = ({ + value, + setValue, + onLockedChange, + mode, + initialUid, + options = [], + placeholder = "Enter value", + autoFocus, + disabled, + initialIsLocked, +}: FuzzySelectInputProps) => { + const [isLocked, setIsLocked] = useState(initialIsLocked || false); + const [query, setQuery] = useState(() => value?.text || ""); + const [isOpen, setIsOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const [isFocused, setIsFocused] = useState(false); + + const menuRef = useRef(null); + const inputRef = useRef(null); + + const filteredItems = useMemo(() => { + if (!query) return options; + return fuzzy + .filter(query, options, { extract: (item) => item.text }) + .map((result) => result.original); + }, [query, options]); + + const handleSelect = useCallback( + (item: T) => { + if (mode === "create" && item.uid && item.uid !== initialUid) { + setIsLocked(true); + setQuery(item.text); + setValue(item); + setIsOpen(false); + onLockedChange?.(true); + } else { + setQuery(item.text); + setValue(item); + setIsOpen(false); + } + }, + [mode, initialUid, setValue, onLockedChange], + ); + + const handleClear = useCallback(() => { + setIsLocked(false); + setQuery(""); + setValue({ ...value, text: "", uid: "" } as T); + onLockedChange?.(false); + }, [value, setValue, onLockedChange]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((prev) => + prev < filteredItems.length - 1 ? prev + 1 : prev, + ); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((prev) => (prev > 0 ? prev - 1 : 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + if (isOpen && filteredItems[activeIndex]) { + handleSelect(filteredItems[activeIndex]); + } + } else if (e.key === "Escape") { + e.preventDefault(); + setIsOpen(false); + } + }, + [filteredItems, activeIndex, isOpen, handleSelect], + ); + + useEffect(() => { + if (mode === "create" && !isLocked) { + setValue({ text: query, uid: "" } as T); + } + }, [query, mode, isLocked, setValue]); + + useEffect(() => { + if (isFocused && filteredItems.length > 0 && query) { + setIsOpen(true); + } else { + setIsOpen(false); + } + }, [filteredItems.length, query, isFocused]); + + useEffect(() => { + setActiveIndex(0); + }, [filteredItems]); + + useEffect(() => { + if (menuRef.current && isOpen) { + const activeElement = menuRef.current.children[ + activeIndex + ] as HTMLElement; + if (activeElement) { + activeElement.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + } + }, [activeIndex, isOpen]); + + if (mode === "edit") { + return ( +