a tool for shared writing and social publishing
at update/delete-blocks 287 lines 8.2 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 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}