a tool for shared writing and social publishing
at main 7.2 kB view raw
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 lowerSearchValue = searchValue.toLocaleLowerCase(); 51 const matchesName = command.name 52 .toLocaleLowerCase() 53 .includes(lowerSearchValue); 54 const matchesAlternate = command.alternateNames?.some((altName) => 55 altName.toLocaleLowerCase().includes(lowerSearchValue) 56 ) ?? false; 57 const matchesSearch = matchesName || matchesAlternate; 58 const isVisible = !pub || !command.hiddenInPublication; 59 return matchesSearch && isVisible; 60 }); 61 62 useEffect(() => { 63 if ( 64 !highlighted || 65 !commandResults.find((result) => result.name === highlighted) 66 ) 67 setHighlighted(commandResults[0]?.name); 68 if (commandResults.length === 1) { 69 setHighlighted(commandResults[0].name); 70 } 71 }, [commandResults, setHighlighted, highlighted]); 72 73 useEffect(() => { 74 let listener = async (e: KeyboardEvent) => { 75 let reverseDir = ref.current?.dataset.side === "top"; 76 let currentHighlightIndex = commandResults.findIndex( 77 (command: { name: string }) => 78 highlighted && command.name === highlighted, 79 ); 80 81 if (reverseDir ? e.key === "ArrowUp" : e.key === "ArrowDown") { 82 setHighlighted( 83 commandResults[ 84 currentHighlightIndex === commandResults.length - 1 || 85 currentHighlightIndex === undefined 86 ? 0 87 : currentHighlightIndex + 1 88 ].name, 89 ); 90 return; 91 } 92 if (reverseDir ? e.key === "ArrowDown" : e.key === "ArrowUp") { 93 setHighlighted( 94 commandResults[ 95 currentHighlightIndex === 0 || 96 currentHighlightIndex === undefined || 97 currentHighlightIndex === -1 98 ? commandResults.length - 1 99 : currentHighlightIndex - 1 100 ].name, 101 ); 102 return; 103 } 104 105 // on enter, select the highlighted item 106 if (e.key === "Enter") { 107 undoManager.startGroup(); 108 e.preventDefault(); 109 rep && 110 (await commandResults[currentHighlightIndex]?.onSelect( 111 rep, 112 { 113 ...props, 114 entity_set: entity_set.set, 115 }, 116 undoManager, 117 )); 118 undoManager.endGroup(); 119 return; 120 } 121 }; 122 123 window.addEventListener("keydown", listener); 124 125 return () => window.removeEventListener("keydown", listener); 126 }, [highlighted, setHighlighted, commandResults, rep, entity_set.set, props]); 127 128 return ( 129 <Popover.Root 130 open 131 onOpenChange={(open) => { 132 if (!open) { 133 clearCommandSearchText(); 134 } 135 }} 136 > 137 <Popover.Trigger className="absolute left-0"></Popover.Trigger> 138 <Popover.Portal> 139 <Popover.Content 140 align="start" 141 sideOffset={16} 142 collisionPadding={16} 143 ref={ref} 144 onOpenAutoFocus={(e) => e.preventDefault()} 145 className={` 146 commandMenuContent group/cmd-menu 147 z-20 w-[264px] 148 flex data-[side=top]:items-end items-start 149 `} 150 > 151 <NestedCardThemeProvider> 152 <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"> 153 {commandResults.length === 0 ? ( 154 <div className="w-full text-tertiary text-center italic py-2 px-2 "> 155 No blocks found 156 </div> 157 ) : ( 158 commandResults.map((result, index) => ( 159 <div key={index} className="contents"> 160 <CommandResult 161 name={result.name} 162 icon={result.icon} 163 onSelect={() => { 164 rep && 165 result.onSelect( 166 rep, 167 { 168 ...props, 169 entity_set: entity_set.set, 170 }, 171 undoManager, 172 ); 173 }} 174 highlighted={highlighted} 175 setHighlighted={(highlighted) => 176 setHighlighted(highlighted) 177 } 178 /> 179 {commandResults[index + 1] && 180 result.type !== commandResults[index + 1].type && ( 181 <hr className="mx-2 my-0.5 border-border" /> 182 )} 183 </div> 184 )) 185 )} 186 </div> 187 </NestedCardThemeProvider> 188 </Popover.Content> 189 </Popover.Portal> 190 </Popover.Root> 191 ); 192}; 193 194const CommandResult = (props: { 195 name: string; 196 icon: React.ReactNode; 197 onSelect: () => void; 198 highlighted: string | undefined; 199 setHighlighted: (state: string | undefined) => void; 200}) => { 201 let isHighlighted = props.highlighted === props.name; 202 203 return ( 204 <button 205 className={`commandResult menuItem text-secondary font-normal! py-0.5! mx-1 pl-0! ${isHighlighted && "bg-[var(--accent-light)]!"}`} 206 onMouseOver={() => { 207 props.setHighlighted(props.name); 208 }} 209 onMouseDown={(e) => { 210 e.preventDefault(); 211 props.onSelect(); 212 }} 213 > 214 <div className="text-tertiary w-8 shrink-0 flex justify-center"> 215 {props.icon} 216 </div> 217 {props.name} 218 </button> 219 ); 220}; 221function usePublicationContext() { 222 throw new Error("Function not implemented."); 223}