forked from
leaflet.pub/leaflet
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}