diff --git a/apps/roam/src/components/LeftSidebarCommands.tsx b/apps/roam/src/components/LeftSidebarCommands.tsx new file mode 100644 index 000000000..c3f74e038 --- /dev/null +++ b/apps/roam/src/components/LeftSidebarCommands.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { Popover, Position, Button, Menu, MenuItem } from "@blueprintjs/core"; +import { OnloadArgs } from "roamjs-components/types"; +import { createDiscourseNodeFromCommand } from "~/utils/registerCommandPaletteCommands"; + +export const cleanCommandName = (name: string): string => { + if (name.startsWith("{") && name.endsWith("}")) + name = name.substring(1, name.length - 1); + name = name.trim(); + // sentence case + name = name.charAt(0).toUpperCase() + name.slice(1); + return name; +}; + +export const commands: Record< + string, + (onloadArgs: OnloadArgs) => Promise +> = { + /* eslint-disable @typescript-eslint/require-await */ + // eslint-disable-next-line @typescript-eslint/naming-convention + "{create node}": async (onloadArgs: OnloadArgs) => { + createDiscourseNodeFromCommand(onloadArgs.extensionAPI); + // typescript-eslint/naming-convention + }, + /* eslint-enable @typescript-eslint/require-await */ +}; + +export const SidebarCommandPopover = ({ + onSelect, +}: { + onSelect: (value: string) => void; +}) => { + return ( + + {Object.keys(commands).map((commandName) => ( + onSelect(commandName)} + /> + ))} + + } + position={Position.BOTTOM_LEFT} + > + + + ) : ( +
+ {label} +
+ )} ); })} @@ -160,8 +184,10 @@ const SectionChildren = ({ const PersonalSectionItem = ({ section, + onloadArgs, }: { section: LeftSidebarPersonalSectionConfig; + onloadArgs: OnloadArgs; }) => { const titleRef = parseReference(section.text); const blockText = useMemo( @@ -213,13 +239,20 @@ const PersonalSectionItem = ({ ); }; -const PersonalSections = ({ config }: { config: LeftSidebarConfig }) => { +const PersonalSections = ({ + config, + onloadArgs, +}: { + config: LeftSidebarConfig; + onloadArgs: OnloadArgs; +}) => { const sections = config.personal.sections || []; if (!sections.length) return null; @@ -228,14 +261,20 @@ const PersonalSections = ({ config }: { config: LeftSidebarConfig }) => {
{sections.map((section) => (
- +
))}
); }; -const GlobalSection = ({ config }: { config: LeftSidebarConfig["global"] }) => { +const GlobalSection = ({ + config, + onloadArgs, +}: { + config: LeftSidebarConfig["global"]; + onloadArgs: OnloadArgs; +}) => { const [isOpen, setIsOpen] = useState( !!config.settings?.folded.value, ); @@ -267,10 +306,16 @@ const GlobalSection = ({ config }: { config: LeftSidebarConfig["global"] }) => { {isCollapsable ? ( - + ) : ( - + )} ); @@ -413,8 +458,8 @@ const LeftSidebarView = ({ onloadArgs }: { onloadArgs: OnloadArgs }) => { return ( <> - - + + ); }; @@ -444,7 +489,7 @@ const migrateFavorites = async () => { } const results = window.roamAlphaAPI.q(` - [:find ?uid + [:find ?uid :where [?e :page/sidebar] [?e :block/uid ?uid]] `); diff --git a/apps/roam/src/components/settings/LeftSidebarGlobalSettings.tsx b/apps/roam/src/components/settings/LeftSidebarGlobalSettings.tsx index 838346ab5..96c2ca993 100644 --- a/apps/roam/src/components/settings/LeftSidebarGlobalSettings.tsx +++ b/apps/roam/src/components/settings/LeftSidebarGlobalSettings.tsx @@ -19,6 +19,10 @@ import { refreshAndNotify } from "~/components/LeftSidebarView"; import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; import posthog from "posthog-js"; +import { + commands, + SidebarCommandPopover, +} from "~/components/LeftSidebarCommands"; const pagesToUids = (pages: RoamBasicNode[]) => pages.map((p) => p.text); @@ -92,7 +96,11 @@ const LeftSidebarGlobalSectionsContent = ({ const [isInitializing, setIsInitializing] = useState(true); const [isExpanded, setIsExpanded] = useState(true); - const pageNames = useMemo(() => getAllPageNames(), []); + const commandNames = useMemo(() => Object.keys(commands), []); + const pageAndCommandNames = useMemo( + () => [...commandNames, ...getAllPageNames()], + [commandNames], + ); useEffect(() => { const initialize = async () => { @@ -182,11 +190,21 @@ const LeftSidebarGlobalSectionsContent = ({ [pages, childrenUid], ); + const resetAutocomplete = useCallback((nextValue = "") => { + setNewPageInput(nextValue); + + // AutocompleteInput renders from its internal `query` state, which is only + // initialized from the external `value` prop on mount. Bump the key to remount + // it so the displayed input reflects the new parent state. + setAutocompleteKey((prev) => prev + 1); + }, []); + const addPage = useCallback( async (pageName: string) => { if (!pageName || !childrenUid) return; - - const targetUid = getPageUidByPageTitle(pageName); + const targetUid = commands[pageName] + ? pageName + : getPageUidByPageTitle(pageName); if (pages.some((p) => p.text === targetUid)) { console.warn(`Page "${pageName}" already exists in global section`); return; @@ -212,8 +230,7 @@ const LeftSidebarGlobalSectionsContent = ({ pagesToUids(updatedPages), ); - setNewPageInput(""); - setAutocompleteKey((prev) => prev + 1); + resetAutocomplete(""); posthog.capture("Left Sidebar Global Settings: Page Added", { pageName, }); @@ -226,7 +243,7 @@ const LeftSidebarGlobalSectionsContent = ({ }); } }, - [childrenUid, pages], + [childrenUid, pages, resetAutocomplete], ); const removePage = useCallback( @@ -262,7 +279,9 @@ const LeftSidebarGlobalSectionsContent = ({ const isAddButtonDisabled = useMemo(() => { if (!newPageInput) return true; - const targetUid = getPageUidByPageTitle(newPageInput); + const targetUid = commands[newPageInput] + ? newPageInput + : getPageUidByPageTitle(newPageInput); return !targetUid || pages.some((p) => p.text === targetUid); }, [newPageInput, pages]); @@ -335,7 +354,7 @@ const LeftSidebarGlobalSectionsContent = ({ value={newPageInput} setValue={handlePageInputChange} placeholder="Add page…" - options={pageNames} + options={pageAndCommandNames} maxItemsDisplayed={50} autoFocus onConfirm={() => void addPage(newPageInput)} @@ -348,6 +367,7 @@ const LeftSidebarGlobalSectionsContent = ({ onClick={() => void addPage(newPageInput)} title="Add page" /> + {pages.length > 0 ? (
diff --git a/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx b/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx index 74487b780..30cbe215e 100644 --- a/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx +++ b/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx @@ -39,6 +39,10 @@ import { refreshAndNotify } from "~/components/LeftSidebarView"; import { memo, Dispatch, SetStateAction } from "react"; import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; import posthog from "posthog-js"; +import { + commands, + SidebarCommandPopover, +} from "~/components/LeftSidebarCommands"; /* eslint-disable @typescript-eslint/naming-convention */ export const sectionsToBlockProps = ( @@ -329,14 +333,22 @@ const SectionItem = memo( [setSections, sectionsRef], ); + const resetAutocomplete = useCallback((nextValue = "") => { + setChildInput(nextValue); + + // AutocompleteInput renders from its internal `query` state, which is only + // initialized from the external `value` prop on mount. Bump the key to remount + // it so the displayed input reflects the new parent state. + setChildInputKey((prev) => prev + 1); + }, []); + const handleAddChild = useCallback(async () => { if (childInput && section.childrenUid) { await addChildToSection(section, section.childrenUid, childInput); - setChildInput(""); - setChildInputKey((prev) => prev + 1); + resetAutocomplete(""); refreshAndNotify(); } - }, [childInput, section, addChildToSection]); + }, [childInput, section, addChildToSection, resetAutocomplete]); const sectionWithoutSettingsAndChildren = (!section.settings && section.children?.length === 0) || @@ -432,6 +444,7 @@ const SectionItem = memo( onClick={() => void handleAddChild()} title="Add child" /> +
{(section.children || []).length > 0 && ( @@ -693,6 +706,10 @@ const LeftSidebarPersonalSectionsContent = ({ }, [sections, settingsDialogSectionUid]); const pageNames = useMemo(() => getAllPageNames(), []); + const pageAndCommandNames = useMemo( + () => [...Object.keys(commands), ...pageNames], + [pageNames], + ); if (!personalSectionUid) { return null; @@ -735,7 +752,7 @@ const LeftSidebarPersonalSectionsContent = ({ { + const activeElement = document.activeElement; + const isFocusedTextarea = + activeElement instanceof HTMLTextAreaElement && + activeElement.classList.contains("rm-block-input") && + getUids(activeElement).blockUid === uid; + if (isFocusedTextarea) { + return { + selectionStart: activeElement.selectionStart, + selectionEnd: activeElement.selectionEnd, + selectedText: activeElement.value.substring( + activeElement.selectionStart, + activeElement.selectionEnd, + ), + }; + } + const textareas = document.querySelectorAll("textarea.rm-block-input"); + for (const el of textareas) { + const textarea = el as HTMLTextAreaElement; + if (getUids(textarea).blockUid === uid) { + return { + selectionStart: textarea.selectionStart, + selectionEnd: textarea.selectionEnd, + selectedText: textarea.value.substring( + textarea.selectionStart, + textarea.selectionEnd, + ), + }; + } + } + const textLength = (getTextByBlockUid(uid) || "").length; + return { + selectionStart: textLength, + selectionEnd: textLength, + selectedText: "", + }; +}; + +export const createDiscourseNodeFromCommand = ( + extensionAPI: OnloadArgs["extensionAPI"], +) => { + posthog.capture("Discourse Node: Create Command Triggered"); + const focusedBlock = window.roamAlphaAPI.ui.getFocusedBlock(); + const uid = focusedBlock?.["block-uid"]; + const windowId = focusedBlock?.["window-id"] || "main-window"; + + const { selectionStart, selectionEnd, selectedText } = uid + ? getBlockSelection(uid) + : { selectionStart: 0, selectionEnd: 0, selectedText: "" }; + + renderModifyNodeDialog({ + mode: "create", + nodeType: "", + initialValue: { text: selectedText, uid: "" }, + extensionAPI, + onSuccess: async (result) => { + if (!uid) { + renderToast({ + id: "create-discourse-node-command-no-block", + content: "No block focused to insert a discourse node.", + }); + return; + } + const originalText = getTextByBlockUid(uid) || ""; + const pageRef = `[[${result.text}]]`; + const newText = `${originalText.substring(0, selectionStart)}${pageRef}${originalText.substring(selectionEnd)}`; + const newCursorPosition = selectionStart + pageRef.length; + + await updateBlock({ uid, text: newText }); + + await window.roamAlphaAPI.ui.setBlockFocusAndSelection({ + location: { + // eslint-disable-next-line @typescript-eslint/naming-convention + "block-uid": uid, + // eslint-disable-next-line @typescript-eslint/naming-convention + "window-id": windowId, + }, + selection: { start: newCursorPosition }, + }); + return; + }, + onClose: () => {}, + }); +}; + export const registerCommandPaletteCommands = (onloadArgs: OnloadArgs) => { const { extensionAPI } = onloadArgs; @@ -166,95 +257,6 @@ export const registerCommandPaletteCommands = (onloadArgs: OnloadArgs) => { renderSettings({ onloadArgs }); }; - type BlockSelection = { - selectionStart: number; - selectionEnd: number; - selectedText: string; - }; - - const getBlockSelection = (uid: string): BlockSelection => { - const activeElement = document.activeElement; - const isFocusedTextarea = - activeElement instanceof HTMLTextAreaElement && - activeElement.classList.contains("rm-block-input") && - getUids(activeElement).blockUid === uid; - if (isFocusedTextarea) { - return { - selectionStart: activeElement.selectionStart, - selectionEnd: activeElement.selectionEnd, - selectedText: activeElement.value.substring( - activeElement.selectionStart, - activeElement.selectionEnd, - ), - }; - } - const textareas = document.querySelectorAll("textarea.rm-block-input"); - for (const el of textareas) { - const textarea = el as HTMLTextAreaElement; - if (getUids(textarea).blockUid === uid) { - return { - selectionStart: textarea.selectionStart, - selectionEnd: textarea.selectionEnd, - selectedText: textarea.value.substring( - textarea.selectionStart, - textarea.selectionEnd, - ), - }; - } - } - const textLength = (getTextByBlockUid(uid) || "").length; - return { - selectionStart: textLength, - selectionEnd: textLength, - selectedText: "", - }; - }; - - const createDiscourseNodeFromCommand = () => { - posthog.capture("Discourse Node: Create Command Triggered"); - const focusedBlock = window.roamAlphaAPI.ui.getFocusedBlock(); - const uid = focusedBlock?.["block-uid"]; - const windowId = focusedBlock?.["window-id"] || "main-window"; - - const { selectionStart, selectionEnd, selectedText } = uid - ? getBlockSelection(uid) - : { selectionStart: 0, selectionEnd: 0, selectedText: "" }; - - renderModifyNodeDialog({ - mode: "create", - nodeType: "", - initialValue: { text: selectedText, uid: "" }, - extensionAPI, - onSuccess: async (result) => { - if (!uid) { - renderToast({ - id: "create-discourse-node-command-no-block", - content: "No block focused to insert a discourse node.", - }); - return; - } - const originalText = getTextByBlockUid(uid) || ""; - const pageRef = `[[${result.text}]]`; - const newText = `${originalText.substring(0, selectionStart)}${pageRef}${originalText.substring(selectionEnd)}`; - const newCursorPosition = selectionStart + pageRef.length; - - await updateBlock({ uid, text: newText }); - - await window.roamAlphaAPI.ui.setBlockFocusAndSelection({ - location: { - // eslint-disable-next-line @typescript-eslint/naming-convention - "block-uid": uid, - // eslint-disable-next-line @typescript-eslint/naming-convention - "window-id": windowId, - }, - selection: { start: newCursorPosition }, - }); - return; - }, - onClose: () => {}, - }); - }; - const toggleDiscourseContextOverlay = async () => { const currentValue = (extensionAPI.settings.get("discourse-context-overlay") as boolean) ?? @@ -312,9 +314,8 @@ export const registerCommandPaletteCommands = (onloadArgs: OnloadArgs) => { }; // Roam organizes commands alphabetically - void addCommand( - "DG: Create/Insert discourse node", - createDiscourseNodeFromCommand, + void addCommand("DG: Create/Insert discourse node", () => + createDiscourseNodeFromCommand(extensionAPI), ); void addCommand("DG: Export - Current page", exportCurrentPage); void addCommand("DG: Export - Discourse graph", exportDiscourseGraph);