a tool for shared writing and social publishing
1import { useEffect, useRef, useState } from "react"; 2import * as Popover from "@radix-ui/react-popover"; 3import { blockCommands } from "./BlockCommands"; 4import { useReplicache } from "src/replicache"; 5import { useEntitySetContext } from "components/EntitySetProvider"; 6import { NestedCardThemeProvider } from "components/ThemeManager/ThemeProvider"; 7import { UndoManager } from "src/undoManager"; 8import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 9import { setEditorState, useEditorStates } from "src/state/useEditorState"; 10 11type Props = { 12 parent: string; 13 entityID: string | null; 14 position: string | null; 15 nextPosition: string | null; 16 factID?: string | undefined; 17 first?: boolean; 18 className?: string; 19}; 20 21export const BlockCommandBar = ({ 22 props, 23 searchValue, 24}: { 25 props: Props; 26 searchValue: string; 27}) => { 28 let ref = useRef<HTMLDivElement>(null); 29 30 let [highlighted, setHighlighted] = useState<string | undefined>(undefined); 31 32 let { rep, undoManager } = useReplicache(); 33 let entity_set = useEntitySetContext(); 34 let { data: pub } = useLeafletPublicationData(); 35 36 // This clears '/' AND anything typed after it 37 const clearCommandSearchText = () => { 38 if (!props.entityID) return; 39 const entityID = props.entityID; 40 41 const existingState = useEditorStates.getState().editorStates[entityID]; 42 if (!existingState) return; 43 44 const tr = existingState.editor.tr; 45 tr.deleteRange(1, tr.doc.content.size - 1); 46 setEditorState(entityID, { editor: existingState.editor.apply(tr) }); 47 }; 48 49 let commandResults = blockCommands.filter((command) => { 50 const matchesSearch = command.name 51 .toLocaleLowerCase() 52 .includes(searchValue.toLocaleLowerCase()); 53 const isVisible = !pub || !command.hiddenInPublication; 54 return matchesSearch && isVisible; 55 }); 56 57 useEffect(() => { 58 if ( 59 !highlighted || 60 !commandResults.find((result) => result.name === highlighted) 61 ) 62 setHighlighted(commandResults[0]?.name); 63 if (commandResults.length === 1) { 64 setHighlighted(commandResults[0].name); 65 } 66 }, [commandResults, setHighlighted, highlighted]); 67 useEffect(() => { 68 let listener = async (e: KeyboardEvent) => { 69 let reverseDir = ref.current?.dataset.side === "top"; 70 let currentHighlightIndex = commandResults.findIndex( 71 (command: { name: string }) => 72 highlighted && command.name === highlighted, 73 ); 74 75 if (reverseDir ? e.key === "ArrowUp" : e.key === "ArrowDown") { 76 setHighlighted( 77 commandResults[ 78 currentHighlightIndex === commandResults.length - 1 || 79 currentHighlightIndex === undefined 80 ? 0 81 : currentHighlightIndex + 1 82 ].name, 83 ); 84 return; 85 } 86 if (reverseDir ? e.key === "ArrowDown" : e.key === "ArrowUp") { 87 setHighlighted( 88 commandResults[ 89 currentHighlightIndex === 0 || 90 currentHighlightIndex === undefined || 91 currentHighlightIndex === -1 92 ? commandResults.length - 1 93 : currentHighlightIndex - 1 94 ].name, 95 ); 96 return; 97 } 98 99 // on enter, select the highlighted item 100 if (e.key === "Enter") { 101 undoManager.startGroup(); 102 e.preventDefault(); 103 rep && 104 (await commandResults[currentHighlightIndex]?.onSelect( 105 rep, 106 { 107 ...props, 108 entity_set: entity_set.set, 109 }, 110 undoManager, 111 )); 112 undoManager.endGroup(); 113 return; 114 } 115 }; 116 window.addEventListener("keydown", listener); 117 118 return () => window.removeEventListener("keydown", listener); 119 }, [highlighted, setHighlighted, commandResults, rep, entity_set.set, props]); 120 121 return ( 122 <Popover.Root 123 open 124 onOpenChange={(open) => { 125 if (!open) { 126 clearCommandSearchText(); 127 } 128 }} 129 > 130 <Popover.Trigger className="absolute left-0"></Popover.Trigger> 131 <Popover.Portal> 132 <Popover.Content 133 align="start" 134 sideOffset={16} 135 collisionPadding={16} 136 ref={ref} 137 onOpenAutoFocus={(e) => e.preventDefault()} 138 className={` 139 commandMenuContent group/cmd-menu 140 z-20 w-[264px] 141 flex data-[side=top]:items-end items-start 142 `} 143 > 144 <NestedCardThemeProvider> 145 <div className="commandMenuResults w-full max-h-(--radix-popover-content-available-height) overflow-auto flex flex-col group-data-[side=top]/cmd-menu:flex-col-reverse bg-bg-page py-1 gap-0.5 border border-border rounded-md shadow-md"> 146 {commandResults.length === 0 ? ( 147 <div className="w-full text-tertiary text-center italic py-2 px-2 "> 148 No blocks found 149 </div> 150 ) : ( 151 commandResults.map((result, index) => ( 152 <div key={index} className="contents"> 153 <CommandResult 154 name={result.name} 155 icon={result.icon} 156 onSelect={() => { 157 rep && 158 result.onSelect( 159 rep, 160 { 161 ...props, 162 entity_set: entity_set.set, 163 }, 164 undoManager, 165 ); 166 }} 167 highlighted={highlighted} 168 setHighlighted={(highlighted) => 169 setHighlighted(highlighted) 170 } 171 /> 172 {commandResults[index + 1] && 173 result.type !== commandResults[index + 1].type && ( 174 <hr className="mx-2 my-0.5 border-border" /> 175 )} 176 </div> 177 )) 178 )} 179 </div> 180 </NestedCardThemeProvider> 181 </Popover.Content> 182 </Popover.Portal> 183 </Popover.Root> 184 ); 185}; 186 187const CommandResult = (props: { 188 name: string; 189 icon: React.ReactNode; 190 onSelect: () => void; 191 highlighted: string | undefined; 192 setHighlighted: (state: string | undefined) => void; 193}) => { 194 let isHighlighted = props.highlighted === props.name; 195 196 return ( 197 <button 198 className={`commandResult text-left flex gap-2 mx-1 pr-2 py-0.5 rounded-md text-secondary ${isHighlighted && "bg-border-light"}`} 199 onMouseOver={() => { 200 props.setHighlighted(props.name); 201 }} 202 onMouseDown={(e) => { 203 e.preventDefault(); 204 props.onSelect(); 205 }} 206 > 207 <div className="text-tertiary w-8 shrink-0 flex justify-center"> 208 {props.icon} 209 </div> 210 {props.name} 211 </button> 212 ); 213}; 214function usePublicationContext() { 215 throw new Error("Function not implemented."); 216}