a tool for shared writing and social publishing
at feature/footnotes 290 lines 8.4 kB view raw
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}