a tool for shared writing and social publishing
1import { useEffect } from "react";
2import { useUIState } from "src/useUIState";
3import { useEditorStates } from "src/state/useEditorState";
4
5import { isTextBlock } from "src/utils/isTextBlock";
6import { focusBlock } from "src/utils/focusBlock";
7import { elementId } from "src/utils/elementId";
8import { indent, outdent } from "src/utils/list-operations";
9import { generateKeyBetween } from "fractional-indexing";
10import { v7 } from "uuid";
11import { BlockProps } from "./Block";
12import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache";
13import { useEntitySetContext } from "components/EntitySetProvider";
14import { Replicache } from "replicache";
15import { deleteBlock } from "src/utils/deleteBlock";
16import { entities } from "drizzle/schema";
17import { scanIndex } from "src/replicache/utils";
18
19export function useBlockKeyboardHandlers(
20 props: BlockProps,
21 areYouSure: boolean,
22 setAreYouSure: (value: boolean) => void,
23) {
24 let { rep, undoManager } = useReplicache();
25 let entity_set = useEntitySetContext();
26
27 let isSelected = useUIState((s) => {
28 let selectedBlocks = s.selectedBlocks;
29 return !!s.selectedBlocks.find((b) => b.value === props.entityID);
30 });
31
32 useEffect(() => {
33 if (!isSelected || !rep) return;
34 let listener = async (e: KeyboardEvent) => {
35 // keymapping for textBlocks is handled in TextBlock/keymap
36 if (e.defaultPrevented) return;
37 //if no permissions, do nothing
38 if (!entity_set.permissions.write) return;
39 let command = {
40 Tab,
41 ArrowUp,
42 ArrowDown,
43 Backspace,
44 Enter,
45 Escape,
46 j,
47 k,
48 }[e.key];
49
50 let el = e.target as HTMLElement;
51 if (
52 (el.tagName === "LABEL" ||
53 el.tagName === "INPUT" ||
54 el.tagName === "TEXTAREA" ||
55 el.tagName === "SELECT" ||
56 el.contentEditable === "true") &&
57 !isTextBlock[props.type]
58 ) {
59 if ((el as HTMLInputElement).value !== "" || e.key === "Tab") return;
60 }
61 if (!AllowedIfTextBlock.includes(e.key) && isTextBlock[props.type])
62 return;
63
64 undoManager.startGroup();
65 await command?.({
66 e,
67 props,
68 rep,
69 entity_set,
70 areYouSure,
71 setAreYouSure,
72 });
73 setTimeout(() => undoManager.endGroup(), 100);
74 };
75 window.addEventListener("keydown", listener);
76 return () => window.removeEventListener("keydown", listener);
77 }, [entity_set, isSelected, props, rep, areYouSure, setAreYouSure]);
78}
79
80type Args = {
81 e: KeyboardEvent;
82 props: BlockProps;
83 rep: Replicache<ReplicacheMutators>;
84 entity_set: { set: string };
85 areYouSure: boolean;
86 setAreYouSure: (value: boolean) => void;
87};
88
89const AllowedIfTextBlock = ["Tab"];
90
91async function Tab({ e, props, rep }: Args) {
92 // if tab or shift tab, indent or outdent
93 if (useUIState.getState().selectedBlocks.length > 1) return false;
94 let { foldedBlocks, toggleFold } = useUIState.getState();
95 if (e.shiftKey) {
96 e.preventDefault();
97 await outdent(props, props.previousBlock, rep, { foldedBlocks, toggleFold });
98 } else {
99 e.preventDefault();
100 if (props.previousBlock) {
101 await indent(props, props.previousBlock, rep, { foldedBlocks, toggleFold });
102 }
103 }
104}
105
106function j(args: Args) {
107 if (args.e.ctrlKey || args.e.metaKey) ArrowDown(args);
108}
109function ArrowDown({ e, props }: Args) {
110 e.preventDefault();
111 let nextBlock = props.nextBlock;
112 if (nextBlock && useUIState.getState().selectedBlocks.length <= 1)
113 focusBlock(nextBlock, {
114 type: "top",
115 left: useEditorStates.getState().lastXPosition,
116 });
117 if (!nextBlock) return;
118}
119
120function k(args: Args) {
121 if (args.e.ctrlKey || args.e.metaKey) ArrowUp(args);
122}
123function ArrowUp({ e, props }: Args) {
124 e.preventDefault();
125 let prevBlock = props.previousBlock;
126 if (prevBlock && useUIState.getState().selectedBlocks.length <= 1) {
127 focusBlock(prevBlock, {
128 type: "bottom",
129 left: useEditorStates.getState().lastXPosition,
130 });
131 }
132 if (!prevBlock) return;
133}
134
135let debounced: null | number = null;
136async function Backspace({ e, props, rep, areYouSure, setAreYouSure }: Args) {
137 // if this is a textBlock, let the textBlock/keymap handle the backspace
138 // if its an input, label, or teatarea with content, do nothing (do the broswer default instead)
139 let el = e.target as HTMLElement;
140 if (
141 el.tagName === "LABEL" ||
142 el.tagName === "INPUT" ||
143 el.tagName === "TEXTAREA" ||
144 el.contentEditable === "true"
145 ) {
146 if ((el as HTMLInputElement).value !== "") return;
147 }
148
149 // if the block is a card, mailbox, rsvp, or poll...
150 if (
151 props.type === "card" ||
152 props.type === "mailbox" ||
153 props.type === "rsvp" ||
154 props.type === "poll"
155 ) {
156 // ...and areYouSure state is false, set it to true
157 if (!areYouSure) {
158 setAreYouSure(true);
159 debounced = window.setTimeout(() => {
160 debounced = null;
161 }, 300);
162 return;
163 }
164 // ... and areYouSure state is true,
165 // and the user is not in an input or textarea, remove it
166 // if there is a page to close, close it
167 if (areYouSure) {
168 e.preventDefault();
169 if (debounced) {
170 window.clearTimeout(debounced);
171 debounced = window.setTimeout(() => {
172 debounced = null;
173 }, 300);
174 return;
175 }
176 return deleteBlock([props.entityID].flat(), rep);
177 }
178 }
179
180 e.preventDefault();
181 await rep.mutate.removeBlock({ blockEntity: props.entityID });
182 useUIState.getState().closePage(props.entityID);
183 let prevBlock = props.previousBlock;
184 if (prevBlock) focusBlock(prevBlock, { type: "end" });
185}
186
187async function Enter({ e, props, rep, entity_set }: Args) {
188 let newEntityID = v7();
189 let position;
190 let el = e.target as HTMLElement;
191 if (
192 el.tagName === "LABEL" ||
193 el.tagName === "INPUT" ||
194 el.tagName === "TEXTAREA" ||
195 el.contentEditable === "true"
196 )
197 return;
198
199 if (e.ctrlKey || e.metaKey) {
200 if (props.listData) {
201 rep?.mutate.toggleTodoState({
202 entityID: props.entityID,
203 });
204 }
205 return;
206 }
207 if (props.pageType === "canvas") {
208 let el = document.getElementById(elementId.block(props.entityID).container);
209 let [position] =
210 (await rep?.query((tx) =>
211 scanIndex(tx).vae(props.entityID, "canvas/block"),
212 )) || [];
213 if (!position || !el) return;
214
215 let box = el.getBoundingClientRect();
216
217 await rep.mutate.addCanvasBlock({
218 newEntityID,
219 factID: v7(),
220 permission_set: entity_set.set,
221 parent: props.parent,
222 type: "text",
223 position: {
224 x: position.data.position.x,
225 y: position.data.position.y + box.height + 12,
226 },
227 });
228 focusBlock(
229 { type: "text", value: newEntityID, parent: props.parent },
230 { type: "start" },
231 );
232 return;
233 }
234
235 // if it's a list, create a new list item at the same depth
236 if (props.listData) {
237 let hasChild =
238 props.nextBlock?.listData &&
239 props.nextBlock.listData.depth > props.listData.depth;
240 position = generateKeyBetween(
241 hasChild ? null : props.position,
242 props.nextPosition,
243 );
244 await rep?.mutate.addBlock({
245 newEntityID,
246 factID: v7(),
247 permission_set: entity_set.set,
248 parent: hasChild ? props.entityID : props.listData.parent,
249 type: "text",
250 position,
251 });
252 await rep?.mutate.assertFact({
253 entity: newEntityID,
254 attribute: "block/is-list",
255 data: { type: "boolean", value: true },
256 });
257 }
258
259 // if it's not a list, create a new block between current and next block
260 if (!props.listData) {
261 position = generateKeyBetween(props.position, props.nextPosition);
262 await rep?.mutate.addBlock({
263 newEntityID,
264 factID: v7(),
265 permission_set: entity_set.set,
266 parent: props.parent,
267 type: "text",
268 position,
269 });
270 }
271 setTimeout(() => {
272 document.getElementById(elementId.block(newEntityID).text)?.focus();
273 }, 10);
274}
275
276function Escape({ e, props, areYouSure, setAreYouSure }: Args) {
277 e.preventDefault();
278 if (areYouSure) {
279 setAreYouSure(false);
280 focusBlock(
281 { type: "card", value: props.entityID, parent: props.parent },
282 { type: "start" },
283 );
284 }
285
286 useUIState.setState({ selectedBlocks: [] });
287 useUIState.setState({
288 focusedEntity: { entityType: "page", entityID: props.parent },
289 });
290}