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}