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