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 command?.({
66 e,
67 props,
68 rep,
69 entity_set,
70 areYouSure,
71 setAreYouSure,
72 });
73 undoManager.endGroup();
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
91function Tab({ e, props, rep }: Args) {
92 // if tab or shift tab, indent or outdent
93 if (useUIState.getState().selectedBlocks.length > 1) return false;
94 if (e.shiftKey) {
95 e.preventDefault();
96 outdent(props, props.previousBlock, rep);
97 } else {
98 e.preventDefault();
99 if (props.previousBlock) indent(props, props.previousBlock, rep);
100 }
101}
102
103function j(args: Args) {
104 if (args.e.ctrlKey || args.e.metaKey) ArrowDown(args);
105}
106function ArrowDown({ e, props }: Args) {
107 e.preventDefault();
108 let nextBlock = props.nextBlock;
109 if (nextBlock && useUIState.getState().selectedBlocks.length <= 1)
110 focusBlock(nextBlock, {
111 type: "top",
112 left: useEditorStates.getState().lastXPosition,
113 });
114 if (!nextBlock) return;
115}
116
117function k(args: Args) {
118 if (args.e.ctrlKey || args.e.metaKey) ArrowUp(args);
119}
120function ArrowUp({ e, props }: Args) {
121 e.preventDefault();
122 let prevBlock = props.previousBlock;
123 if (prevBlock && useUIState.getState().selectedBlocks.length <= 1) {
124 focusBlock(prevBlock, {
125 type: "bottom",
126 left: useEditorStates.getState().lastXPosition,
127 });
128 }
129 if (!prevBlock) return;
130}
131
132let debounced: null | number = null;
133async function Backspace({ e, props, rep, areYouSure, setAreYouSure }: Args) {
134 // if this is a textBlock, let the textBlock/keymap handle the backspace
135 // if its an input, label, or teatarea with content, do nothing (do the broswer default instead)
136 let el = e.target as HTMLElement;
137 if (
138 el.tagName === "LABEL" ||
139 el.tagName === "INPUT" ||
140 el.tagName === "TEXTAREA" ||
141 el.contentEditable === "true"
142 ) {
143 if ((el as HTMLInputElement).value !== "") return;
144 }
145
146 // if the block is a card, mailbox, rsvp, or poll...
147 if (
148 props.type === "card" ||
149 props.type === "mailbox" ||
150 props.type === "rsvp" ||
151 props.type === "poll"
152 ) {
153 // ...and areYouSure state is false, set it to true
154 if (!areYouSure) {
155 setAreYouSure(true);
156 debounced = window.setTimeout(() => {
157 debounced = null;
158 }, 300);
159 return;
160 }
161 // ... and areYouSure state is true,
162 // and the user is not in an input or textarea, remove it
163 // if there is a page to close, close it
164 if (areYouSure) {
165 e.preventDefault();
166 if (debounced) {
167 window.clearTimeout(debounced);
168 debounced = window.setTimeout(() => {
169 debounced = null;
170 }, 300);
171 return;
172 }
173 return deleteBlock([props.entityID].flat(), rep);
174 }
175 }
176
177 e.preventDefault();
178 await rep.mutate.removeBlock({ blockEntity: props.entityID });
179 useUIState.getState().closePage(props.entityID);
180 let prevBlock = props.previousBlock;
181 if (prevBlock) focusBlock(prevBlock, { type: "end" });
182}
183
184async function Enter({ e, props, rep, entity_set }: Args) {
185 let newEntityID = v7();
186 let position;
187 let el = e.target as HTMLElement;
188 if (
189 el.tagName === "LABEL" ||
190 el.tagName === "INPUT" ||
191 el.tagName === "TEXTAREA" ||
192 el.contentEditable === "true"
193 )
194 return;
195
196 if (e.ctrlKey || e.metaKey) {
197 if (props.listData) {
198 rep?.mutate.toggleTodoState({
199 entityID: props.entityID,
200 });
201 }
202 return;
203 }
204 if (props.pageType === "canvas") {
205 let el = document.getElementById(elementId.block(props.entityID).container);
206 let [position] =
207 (await rep?.query((tx) =>
208 scanIndex(tx).vae(props.entityID, "canvas/block"),
209 )) || [];
210 if (!position || !el) return;
211
212 let box = el.getBoundingClientRect();
213
214 await rep.mutate.addCanvasBlock({
215 newEntityID,
216 factID: v7(),
217 permission_set: entity_set.set,
218 parent: props.parent,
219 type: "text",
220 position: {
221 x: position.data.position.x,
222 y: position.data.position.y + box.height + 12,
223 },
224 });
225 focusBlock(
226 { type: "text", value: newEntityID, parent: props.parent },
227 { type: "start" },
228 );
229 return;
230 }
231
232 // if it's a list, create a new list item at the same depth
233 if (props.listData) {
234 let hasChild =
235 props.nextBlock?.listData &&
236 props.nextBlock.listData.depth > props.listData.depth;
237 position = generateKeyBetween(
238 hasChild ? null : props.position,
239 props.nextPosition,
240 );
241 await rep?.mutate.addBlock({
242 newEntityID,
243 factID: v7(),
244 permission_set: entity_set.set,
245 parent: hasChild ? props.entityID : props.listData.parent,
246 type: "text",
247 position,
248 });
249 await rep?.mutate.assertFact({
250 entity: newEntityID,
251 attribute: "block/is-list",
252 data: { type: "boolean", value: true },
253 });
254 }
255
256 // if it's not a list, create a new block between current and next block
257 if (!props.listData) {
258 position = generateKeyBetween(props.position, props.nextPosition);
259 await rep?.mutate.addBlock({
260 newEntityID,
261 factID: v7(),
262 permission_set: entity_set.set,
263 parent: props.parent,
264 type: "text",
265 position,
266 });
267 }
268 setTimeout(() => {
269 document.getElementById(elementId.block(newEntityID).text)?.focus();
270 }, 10);
271}
272
273function Escape({ e, props, areYouSure, setAreYouSure }: Args) {
274 e.preventDefault();
275 if (areYouSure) {
276 setAreYouSure(false);
277 focusBlock(
278 { type: "card", value: props.entityID, parent: props.parent },
279 { type: "start" },
280 );
281 }
282
283 useUIState.setState({ selectedBlocks: [] });
284 useUIState.setState({
285 focusedEntity: { entityType: "page", entityID: props.parent },
286 });
287}