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 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}