a tool for shared writing and social publishing
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #125 from hyperlink-academy/feature/undo

Feature/undo

authored by

Jared Pereira and committed by
GitHub
af2c30da a9a851a7

+959 -516
+20 -15
components/Blocks/BlockCommandBar.tsx
··· 4 4 import { useReplicache } from "src/replicache"; 5 5 import { useEntitySetContext } from "components/EntitySetProvider"; 6 6 import { NestedCardThemeProvider } from "components/ThemeManager/ThemeProvider"; 7 + import { UndoManager } from "src/undoManager"; 7 8 8 9 type Props = { 9 10 parent: string; ··· 26 27 27 28 let [highlighted, setHighlighted] = useState<string | undefined>(undefined); 28 29 29 - let { rep } = useReplicache(); 30 + let { rep, undoManager } = useReplicache(); 30 31 let entity_set = useEntitySetContext(); 31 32 32 33 let commandResults = blockCommands.filter((command) => ··· 44 45 }, [commandResults, setHighlighted, highlighted]); 45 46 useEffect(() => { 46 47 let listener = async (e: KeyboardEvent) => { 47 - let input = document.getElementById("block-search"); 48 48 let reverseDir = ref.current?.dataset.side === "top"; 49 49 let currentHighlightIndex = commandResults.findIndex( 50 50 (command: { name: string }) => ··· 77 77 78 78 // on enter, select the highlighted item 79 79 if (e.key === "Enter") { 80 + undoManager.startGroup(); 80 81 e.preventDefault(); 81 82 rep && 82 - commandResults[currentHighlightIndex]?.onSelect(rep, { 83 - ...props, 84 - entity_set: entity_set.set, 85 - }); 83 + (await commandResults[currentHighlightIndex]?.onSelect( 84 + rep, 85 + { 86 + ...props, 87 + entity_set: entity_set.set, 88 + }, 89 + undoManager, 90 + )); 91 + undoManager.endGroup(); 86 92 return; 87 93 } 88 94 89 95 // radix menu component handles esc 90 96 if (e.key === "Escape") return; 91 - 92 - // any keypress that is not up down, left right, enter, esc, space focuses the search 93 - if (input) { 94 - input.focus(); 95 - } 96 97 }; 97 98 window.addEventListener("keydown", listener); 98 99 ··· 130 131 icon={result.icon} 131 132 onSelect={() => { 132 133 rep && 133 - result.onSelect(rep, { 134 - ...props, 135 - entity_set: entity_set.set, 136 - }); 134 + result.onSelect( 135 + rep, 136 + { 137 + ...props, 138 + entity_set: entity_set.set, 139 + }, 140 + undoManager, 141 + ); 137 142 }} 138 143 highlighted={highlighted} 139 144 setHighlighted={(highlighted) =>
+153 -25
components/Blocks/BlockCommands.tsx
··· 23 23 import { keepFocus } from "components/Toolbar/TextBlockTypeToolbar"; 24 24 import { useEditorStates } from "src/state/useEditorState"; 25 25 import { elementId } from "src/utils/elementId"; 26 + import { UndoManager } from "src/undoManager"; 27 + import { focusBlock } from "src/utils/focusBlock"; 26 28 import { usePollBlockUIState } from "./PollBlock"; 27 29 import { focusElement } from "components/Input"; 28 30 ··· 98 100 onSelect: ( 99 101 rep: Replicache<ReplicacheMutators>, 100 102 props: Props & { entity_set: string }, 101 - ) => void; 103 + undoManager: UndoManager, 104 + ) => Promise<any>; 102 105 }; 103 106 export const blockCommands: Command[] = [ 104 107 // please keep these in the order that they appear in the menu, grouped by type ··· 106 109 name: "Text", 107 110 icon: <ParagraphSmall />, 108 111 type: "text", 109 - onSelect: async (rep, props) => { 112 + onSelect: async (rep, props, um) => { 110 113 props.entityID && clearCommandSearchText(props.entityID); 111 114 let entity = await createBlockWithType(rep, props, "text"); 112 115 clearCommandSearchText(entity); 116 + um.add({ 117 + undo: () => { 118 + keepFocus(entity); 119 + }, 120 + redo: () => { 121 + keepFocus(entity); 122 + }, 123 + }); 124 + 113 125 keepFocus(entity); 114 126 }, 115 127 }, ··· 117 129 name: "Title", 118 130 icon: <Header1Small />, 119 131 type: "text", 120 - onSelect: async (rep, props) => { 121 - props.entityID && clearCommandSearchText(props.entityID); 132 + onSelect: async (rep, props, um) => { 122 133 let entity = await createBlockWithType(rep, props, "heading"); 123 134 await rep.mutate.assertFact({ 124 135 entity, 125 136 attribute: "block/heading-level", 126 137 data: { type: "number", value: 1 }, 127 138 }); 139 + clearCommandSearchText(entity); 140 + um.add({ 141 + undo: () => { 142 + keepFocus(entity); 143 + }, 144 + redo: () => { 145 + keepFocus(entity); 146 + }, 147 + }); 128 148 129 149 keepFocus(entity); 130 150 }, ··· 133 153 name: "Header", 134 154 icon: <Header2Small />, 135 155 type: "text", 136 - onSelect: async (rep, props) => { 137 - props.entityID && clearCommandSearchText(props.entityID); 156 + onSelect: async (rep, props, um) => { 138 157 let entity = await createBlockWithType(rep, props, "heading"); 139 - rep.mutate.assertFact({ 158 + await rep.mutate.assertFact({ 140 159 entity, 141 160 attribute: "block/heading-level", 142 161 data: { type: "number", value: 2 }, 143 162 }); 163 + um.add({ 164 + undo: () => { 165 + keepFocus(entity); 166 + }, 167 + redo: () => { 168 + keepFocus(entity); 169 + }, 170 + }); 144 171 clearCommandSearchText(entity); 145 172 keepFocus(entity); 146 173 }, ··· 149 176 name: "Subheader", 150 177 icon: <Header3Small />, 151 178 type: "text", 152 - onSelect: async (rep, props) => { 153 - props.entityID && clearCommandSearchText(props.entityID); 179 + onSelect: async (rep, props, um) => { 154 180 let entity = await createBlockWithType(rep, props, "heading"); 155 - rep.mutate.assertFact({ 181 + await rep.mutate.assertFact({ 156 182 entity, 157 183 attribute: "block/heading-level", 158 184 data: { type: "number", value: 3 }, 159 185 }); 186 + um.add({ 187 + undo: () => { 188 + keepFocus(entity); 189 + }, 190 + redo: () => { 191 + keepFocus(entity); 192 + }, 193 + }); 160 194 clearCommandSearchText(entity); 161 195 keepFocus(entity); 162 196 }, ··· 166 200 name: "External Link", 167 201 icon: <LinkSmall />, 168 202 type: "block", 169 - onSelect: async (rep, props) => { 170 - createBlockWithType(rep, props, "link"); 203 + onSelect: async (rep, props, um) => { 204 + props.entityID && clearCommandSearchText(props.entityID); 205 + await createBlockWithType(rep, props, "link"); 206 + um.add({ 207 + undo: () => { 208 + props.entityID && keepFocus(props.entityID); 209 + }, 210 + redo: () => {}, 211 + }); 171 212 }, 172 213 }, 173 214 { 174 215 name: "Embed Website", 175 216 icon: <BlockEmbedSmall />, 176 217 type: "block", 177 - onSelect: async (rep, props) => { 178 - createBlockWithType(rep, props, "embed"); 218 + onSelect: async (rep, props, um) => { 219 + props.entityID && clearCommandSearchText(props.entityID); 220 + await createBlockWithType(rep, props, "embed"); 221 + um.add({ 222 + undo: () => { 223 + props.entityID && keepFocus(props.entityID); 224 + }, 225 + redo: () => {}, 226 + }); 179 227 }, 180 228 }, 181 229 { 182 230 name: "Image", 183 231 icon: <BlockImageSmall />, 184 232 type: "block", 185 - onSelect: async (rep, props) => { 233 + onSelect: async (rep, props, um) => { 234 + props.entityID && clearCommandSearchText(props.entityID); 186 235 let entity = await createBlockWithType(rep, props, "image"); 187 236 setTimeout(() => { 188 237 let el = document.getElementById(elementId.block(entity).input); 189 238 el?.focus(); 190 239 }, 100); 240 + um.add({ 241 + undo: () => { 242 + keepFocus(entity); 243 + }, 244 + redo: () => { 245 + let el = document.getElementById(elementId.block(entity).input); 246 + el?.focus(); 247 + }, 248 + }); 191 249 }, 192 250 }, 193 251 { 194 252 name: "Button", 195 253 icon: <BlockButtonSmall />, 196 254 type: "block", 197 - onSelect: async (rep, props) => { 198 - createBlockWithType(rep, props, "button"); 255 + onSelect: async (rep, props, um) => { 256 + props.entityID && clearCommandSearchText(props.entityID); 257 + await createBlockWithType(rep, props, "button"); 258 + um.add({ 259 + undo: () => { 260 + props.entityID && keepFocus(props.entityID); 261 + }, 262 + redo: () => {}, 263 + }); 199 264 }, 200 265 }, 201 266 { 202 267 name: "Mailbox", 203 268 icon: <BlockMailboxSmall />, 204 269 type: "block", 205 - onSelect: async (rep, props) => { 206 - let entity; 207 - createBlockWithType(rep, props, "mailbox"); 270 + onSelect: async (rep, props, um) => { 271 + props.entityID && clearCommandSearchText(props.entityID); 272 + await createBlockWithType(rep, props, "mailbox"); 273 + um.add({ 274 + undo: () => { 275 + props.entityID && keepFocus(props.entityID); 276 + }, 277 + redo: () => {}, 278 + }); 208 279 }, 209 280 }, 210 281 { 211 282 name: "Poll", 212 283 icon: <BlockPollSmall />, 213 284 type: "block", 214 - onSelect: async (rep, props) => { 285 + onSelect: async (rep, props, um) => { 215 286 let entity = await createBlockWithType(rep, props, "poll"); 216 287 let pollOptionEntity = v7(); 217 288 await rep.mutate.addPollOption({ ··· 236 307 ) as HTMLInputElement | null, 237 308 ); 238 309 }, 20); 310 + um.add({ 311 + undo: () => { 312 + props.entityID && keepFocus(props.entityID); 313 + }, 314 + redo: () => { 315 + setTimeout(() => { 316 + focusElement( 317 + document.getElementById( 318 + elementId.block(entity).pollInput(pollOptionEntity), 319 + ) as HTMLInputElement | null, 320 + ); 321 + }, 20); 322 + }, 323 + }); 239 324 }, 240 325 }, 241 326 ··· 245 330 name: "RSVP", 246 331 icon: <RSVPSmall />, 247 332 type: "event", 248 - onSelect: (rep, props) => createBlockWithType(rep, props, "rsvp"), 333 + onSelect: (rep, props) => { 334 + props.entityID && clearCommandSearchText(props.entityID); 335 + return createBlockWithType(rep, props, "rsvp"); 336 + }, 249 337 }, 250 338 { 251 339 name: "Date and Time", 252 340 icon: <BlockCalendarSmall />, 253 341 type: "event", 254 - onSelect: (rep, props) => createBlockWithType(rep, props, "datetime"), 342 + onSelect: (rep, props) => { 343 + props.entityID && clearCommandSearchText(props.entityID); 344 + return createBlockWithType(rep, props, "datetime"); 345 + }, 255 346 }, 256 347 257 348 // PAGE TYPES ··· 260 351 name: "New Page", 261 352 icon: <BlockDocPageSmall />, 262 353 type: "page", 263 - onSelect: async (rep, props) => { 354 + onSelect: async (rep, props, um) => { 355 + props.entityID && clearCommandSearchText(props.entityID); 264 356 let entity = await createBlockWithType(rep, props, "card"); 265 357 266 358 let newPage = v7(); ··· 272 364 type: "doc", 273 365 permission_set: props.entity_set, 274 366 }); 367 + 275 368 useUIState.getState().openPage(props.parent, newPage); 369 + um.add({ 370 + undo: () => { 371 + useUIState.getState().closePage(newPage); 372 + setTimeout( 373 + () => 374 + focusBlock( 375 + { parent: props.parent, value: entity, type: "text" }, 376 + { type: "end" }, 377 + ), 378 + 100, 379 + ); 380 + }, 381 + redo: () => { 382 + useUIState.getState().openPage(props.parent, newPage); 383 + focusPage(newPage, rep, "focusFirstBlock"); 384 + }, 385 + }); 276 386 focusPage(newPage, rep, "focusFirstBlock"); 277 387 }, 278 388 }, ··· 280 390 name: "New Canvas", 281 391 icon: <BlockCanvasPageSmall />, 282 392 type: "page", 283 - onSelect: async (rep, props) => { 393 + onSelect: async (rep, props, um) => { 394 + props.entityID && clearCommandSearchText(props.entityID); 284 395 let entity = await createBlockWithType(rep, props, "card"); 285 396 286 397 let newPage = v7(); ··· 294 405 }); 295 406 useUIState.getState().openPage(props.parent, newPage); 296 407 focusPage(newPage, rep, "focusFirstBlock"); 408 + um.add({ 409 + undo: () => { 410 + useUIState.getState().closePage(newPage); 411 + setTimeout( 412 + () => 413 + focusBlock( 414 + { parent: props.parent, value: entity, type: "text" }, 415 + { type: "end" }, 416 + ), 417 + 100, 418 + ); 419 + }, 420 + redo: () => { 421 + useUIState.getState().openPage(props.parent, newPage); 422 + focusPage(newPage, rep, "focusFirstBlock"); 423 + }, 424 + }); 297 425 }, 298 426 }, 299 427 ];
+73 -60
components/Blocks/TextBlock/index.tsx
··· 52 52 import { TooltipButton } from "components/Buttons"; 53 53 import { v7 } from "uuid"; 54 54 import { focusPage } from "components/Pages"; 55 + import { blockCommands } from "../BlockCommands"; 55 56 56 57 export function TextBlock( 57 58 props: BlockProps & { className?: string; preview?: boolean }, ··· 224 225 )?.editor; 225 226 useEffect(() => { 226 227 if (!editorState) { 227 - let km = TextBlockKeymap(propsRef, repRef); 228 + let km = TextBlockKeymap(propsRef, repRef, rep.undoManager); 228 229 setEditorState(props.entityID, { 229 - keymap: km, 230 230 editor: EditorState.create({ 231 231 schema, 232 232 plugins: [ ··· 261 261 window.open(mark.attrs.href, "_blank"); 262 262 } 263 263 }, []); 264 + let actionTimeout = useRef<number | null>(null); 264 265 let dispatchTransaction = useCallback( 265 266 (tr: Transaction) => { 266 267 useEditorStates.setState((s) => { 267 268 let existingState = s.editorStates[props.entityID]; 268 269 if (!existingState) return s; 270 + let newState = existingState.editor.apply(tr); 271 + let addToHistory = tr.getMeta("addToHistory"); 272 + let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 273 + if (addToHistory !== false && docHasChanges) { 274 + if (actionTimeout.current) { 275 + window.clearTimeout(actionTimeout.current); 276 + } else { 277 + rep.undoManager.startGroup(); 278 + } 279 + 280 + actionTimeout.current = window.setTimeout(() => { 281 + rep.undoManager.endGroup(); 282 + actionTimeout.current = null; 283 + }, 200); 284 + rep.undoManager.add({ 285 + redo: () => { 286 + useEditorStates.setState((oldState) => { 287 + let view = oldState.editorStates[props.entityID]?.view; 288 + if (!view?.hasFocus()) view?.focus(); 289 + return { 290 + editorStates: { 291 + ...oldState.editorStates, 292 + [props.entityID]: { 293 + ...oldState.editorStates[props.entityID]!, 294 + editor: newState, 295 + }, 296 + }, 297 + }; 298 + }); 299 + }, 300 + undo: () => { 301 + useEditorStates.setState((oldState) => { 302 + let view = oldState.editorStates[props.entityID]?.view; 303 + if (!view?.hasFocus()) view?.focus(); 304 + return { 305 + editorStates: { 306 + ...oldState.editorStates, 307 + [props.entityID]: { 308 + ...oldState.editorStates[props.entityID]!, 309 + editor: existingState.editor, 310 + }, 311 + }, 312 + }; 313 + }); 314 + }, 315 + }); 316 + } 317 + 269 318 return { 270 319 editorStates: { 271 320 ...s.editorStates, ··· 277 326 }; 278 327 }); 279 328 }, 280 - [props.entityID], 329 + [props.entityID, rep.undoManager], 281 330 ); 282 331 if (!editorState) return null; 283 332 ··· 295 344 <pre 296 345 data-entityid={props.entityID} 297 346 onBlur={async () => { 347 + if (actionTimeout.current) { 348 + rep.undoManager.endGroup(); 349 + window.clearTimeout(actionTimeout.current); 350 + actionTimeout.current = null; 351 + } 298 352 if (editorState.doc.textContent.startsWith("http")) { 299 353 await addLinkBlock( 300 354 editorState.doc.textContent, ··· 353 407 <TooltipButton 354 408 className={props.className} 355 409 onMouseDown={async () => { 356 - let entity; 357 - if (!props.entityID) { 358 - entity = v7(); 359 - await rep.rep?.mutate.addBlock({ 360 - parent: props.parent, 361 - factID: v7(), 362 - permission_set: entity_set.set, 363 - type: "image", 364 - position: generateKeyBetween( 365 - props.position, 366 - props.nextPosition, 367 - ), 368 - newEntityID: entity, 369 - }); 370 - } else { 371 - entity = props.entityID; 372 - await rep.rep?.mutate.assertFact({ 373 - entity, 374 - attribute: "block/type", 375 - data: { type: "block-type-union", value: "image" }, 376 - }); 377 - } 378 - return entity; 410 + let command = blockCommands.find((f) => f.name === "Image"); 411 + if (!rep.rep) return; 412 + await command?.onSelect( 413 + rep.rep, 414 + { ...props, entity_set: entity_set.set }, 415 + rep.undoManager, 416 + ); 379 417 }} 380 418 side="bottom" 381 419 tooltipContent={ ··· 388 426 <TooltipButton 389 427 className={props.className} 390 428 onMouseDown={async () => { 391 - let entity; 392 - if (!props.entityID) { 393 - entity = v7(); 394 - await rep.rep?.mutate.addBlock({ 395 - parent: props.parent, 396 - factID: v7(), 397 - permission_set: entity_set.set, 398 - type: "card", 399 - position: generateKeyBetween( 400 - props.position, 401 - props.nextPosition, 402 - ), 403 - newEntityID: entity, 404 - }); 405 - } else { 406 - entity = props.entityID; 407 - await rep.rep?.mutate.assertFact({ 408 - entity, 409 - attribute: "block/type", 410 - data: { type: "block-type-union", value: "card" }, 411 - }); 412 - } 413 - 414 - let newPage = v7(); 415 - await rep.rep?.mutate.addPageLinkBlock({ 416 - blockEntity: entity, 417 - firstBlockFactID: v7(), 418 - firstBlockEntity: v7(), 419 - pageEntity: newPage, 420 - type: "doc", 421 - permission_set: entity_set.set, 422 - }); 423 - useUIState.getState().openPage(props.parent, newPage); 424 - rep.rep && focusPage(newPage, rep.rep, "focusFirstBlock"); 429 + let command = blockCommands.find((f) => f.name === "New Page"); 430 + if (!rep.rep) return; 431 + await command?.onSelect( 432 + rep.rep, 433 + { ...props, entity_set: entity_set.set }, 434 + rep.undoManager, 435 + ); 425 436 }} 426 437 side="bottom" 427 438 tooltipContent={ ··· 593 604 const updateReplicache = async () => { 594 605 const update = Y.encodeStateAsUpdate(ydoc); 595 606 await rep.rep?.mutate.assertFact({ 607 + //These undos are handled above in the Prosemirror context 608 + ignoreUndo: true, 596 609 entity: entityID, 597 610 attribute: "block/text", 598 611 data: {
+29 -8
components/Blocks/TextBlock/keymap.ts
··· 23 23 import { indent, outdent } from "src/utils/list-operations"; 24 24 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 25 25 import { isTextBlock } from "src/utils/isTextBlock"; 26 + import { UndoManager } from "src/undoManager"; 26 27 27 28 type PropsRef = MutableRefObject<BlockProps & { entity_set: { set: string } }>; 28 29 export const TextBlockKeymap = ( 29 30 propsRef: PropsRef, 30 31 repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 32 + um: UndoManager, 31 33 ) => 32 34 ({ 33 35 "Meta-b": toggleMark(schema.marks.strong), ··· 45 47 "Ctrl-a": metaA(propsRef, repRef), 46 48 "Meta-a": metaA(propsRef, repRef), 47 49 Tab: () => { 48 - if (useUIState.getState().selectedBlocks.length > 1) return false; 49 - if (!repRef.current || !propsRef.current.previousBlock) return false; 50 - indent(propsRef.current, propsRef.current.previousBlock, repRef.current); 51 - return true; 50 + return um.withUndoGroup(() => { 51 + if (useUIState.getState().selectedBlocks.length > 1) return false; 52 + if (!repRef.current || !propsRef.current.previousBlock) return false; 53 + indent( 54 + propsRef.current, 55 + propsRef.current.previousBlock, 56 + repRef.current, 57 + ); 58 + return true; 59 + }); 52 60 }, 53 - "Shift-Tab": shifttab(propsRef, repRef), 61 + "Shift-Tab": () => { 62 + return um.withUndoGroup(shifttab(propsRef, repRef)); 63 + }, 54 64 Escape: (_state, _dispatch, view) => { 55 65 view?.dom.blur(); 56 66 useUIState.setState(() => ({ ··· 164 174 } 165 175 return true; 166 176 }, 167 - Backspace: backspace(propsRef, repRef), 177 + Backspace: (state, dispatch, view) => 178 + um.withUndoGroup(() => 179 + backspace(propsRef, repRef)(state, dispatch, view), 180 + ), 168 181 "Shift-Backspace": backspace(propsRef, repRef), 169 - Enter: enter(propsRef, repRef), 170 - "Shift-Enter": enter(propsRef, repRef), 182 + Enter: (state, dispatch, view) => { 183 + return um.withUndoGroup(() => 184 + enter(propsRef, repRef)(state, dispatch, view), 185 + ); 186 + }, 187 + "Shift-Enter": (state, dispatch, view) => { 188 + return um.withUndoGroup(() => 189 + enter(propsRef, repRef)(state, dispatch, view), 190 + ); 191 + }, 171 192 "Ctrl-Enter": CtrlEnter(propsRef, repRef), 172 193 "Meta-Enter": CtrlEnter(propsRef, repRef), 173 194 }) as { [key: string]: Command };
+4 -2
components/Blocks/useBlockKeyboardHandlers.ts
··· 21 21 areYouSure: boolean, 22 22 setAreYouSure: (value: boolean) => void, 23 23 ) { 24 - let { rep } = useReplicache(); 24 + let { rep, undoManager } = useReplicache(); 25 25 let entity_set = useEntitySetContext(); 26 26 let isLocked = !!useEntity(props.entityID, "block/is-locked")?.data.value; 27 27 ··· 54 54 if ((el as HTMLInputElement).value !== "" || e.key === "Tab") return; 55 55 } 56 56 57 + undoManager.startGroup(); 57 58 command?.({ 58 59 e, 59 60 props, ··· 63 64 setAreYouSure, 64 65 isLocked, 65 66 }); 67 + undoManager.endGroup(); 66 68 }; 67 69 window.addEventListener("keydown", listener); 68 70 return () => window.removeEventListener("keydown", listener); ··· 168 170 } 169 171 170 172 e.preventDefault(); 171 - rep.mutate.removeBlock({ blockEntity: props.entityID }); 173 + await rep.mutate.removeBlock({ blockEntity: props.entityID }); 172 174 useUIState.getState().closePage(props.entityID); 173 175 let prevBlock = props.previousBlock; 174 176 if (prevBlock) focusBlock(prevBlock, { type: "end" });
+7 -3
components/Buttons.tsx
··· 6 6 CardThemeProvider, 7 7 NestedCardThemeProvider, 8 8 } from "./ThemeManager/ThemeProvider"; 9 + import { useReplicache } from "src/replicache"; 9 10 10 11 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 11 12 export const ButtonPrimary = forwardRef< ··· 144 145 }; 145 146 146 147 export const TooltipButton = (props: { 147 - onMouseDown?: (e: React.MouseEvent) => void; 148 + onMouseDown?: (e: React.MouseEvent) => void | Promise<void>; 148 149 disabled?: boolean; 149 150 className?: string; 150 151 children: React.ReactNode; ··· 153 154 open?: boolean; 154 155 delayDuration?: number; 155 156 }) => { 157 + let { undoManager } = useReplicache(); 156 158 return ( 157 159 // toolbar button does not control the highlight theme setter 158 160 // if toolbar button is updated, be sure to update there as well ··· 163 165 <RadixTooltip.Trigger 164 166 disabled={props.disabled} 165 167 className={props.className} 166 - onMouseDown={(e) => { 168 + onMouseDown={async (e) => { 167 169 e.preventDefault(); 168 - props.onMouseDown && props.onMouseDown(e); 170 + undoManager.startGroup(); 171 + props.onMouseDown && (await props.onMouseDown(e)); 172 + undoManager.endGroup(); 169 173 }} 170 174 > 171 175 {props.children}
+14 -14
components/Icons.tsx
··· 936 936 ); 937 937 }; 938 938 939 - export const SearchTiny = (props: Props) => { 939 + export const UndoTiny = (props: Props) => { 940 940 return ( 941 941 <svg 942 942 width="16" ··· 949 949 <path 950 950 fillRule="evenodd" 951 951 clipRule="evenodd" 952 - d="M2.95685 7.38903C2.5496 5.40568 3.7763 3.51814 5.64053 3.13535C7.50476 2.75255 9.37599 4.00398 9.78324 5.98733C10.1905 7.97069 8.9638 9.85822 7.09957 10.241C5.23534 10.6238 3.36411 9.37239 2.95685 7.38903ZM5.36899 1.81294C2.73276 2.35425 1.08592 4.98923 1.63444 7.66057C2.18297 10.3319 4.73488 12.1047 7.37111 11.5634C8.07196 11.4195 8.70288 11.1276 9.24122 10.7264L13.1848 13.9403C13.613 14.2892 14.2429 14.225 14.5918 13.7969C14.9407 13.3688 14.8764 12.7389 14.4483 12.39L10.5619 9.2227C11.1395 8.20102 11.3616 6.96237 11.1057 5.7158C10.5571 3.04445 8.00522 1.27163 5.36899 1.81294ZM4.83423 6.30333C5.22704 6.22267 5.48009 5.83885 5.39944 5.44604C5.31878 5.05322 4.93496 4.80017 4.54214 4.88083C4.14933 4.96149 3.89628 5.34531 3.97694 5.73812C4.0576 6.13094 4.44142 6.38399 4.83423 6.30333ZM5.22415 7.40592C5.1635 7.06611 4.83887 6.83981 4.49906 6.90045C4.15925 6.9611 3.93295 7.28573 3.99359 7.62554C4.1788 8.66325 5.33436 9.59619 6.85483 9.38256C7.19665 9.33454 7.43481 9.0185 7.38679 8.67668C7.33876 8.33486 7.02273 8.09669 6.68091 8.14472C5.74535 8.27617 5.27738 7.70416 5.22415 7.40592Z" 952 + d="M5.98775 3.14543C6.37828 2.75491 6.37828 2.12174 5.98775 1.73122C5.59723 1.34069 4.96407 1.34069 4.57354 1.73122L1.20732 5.09744C0.816798 5.48796 0.816798 6.12113 1.20732 6.51165L4.57354 9.87787C4.96407 10.2684 5.59723 10.2684 5.98775 9.87787C6.37828 9.48735 6.37828 8.85418 5.98775 8.46366L4.32865 6.80456H9.6299C12.1732 6.80456 13.0856 8.27148 13.0856 9.21676C13.0856 9.84525 12.8932 10.5028 12.5318 10.9786C12.1942 11.4232 11.6948 11.7367 10.9386 11.7367H9.43173C8.87944 11.7367 8.43173 12.1844 8.43173 12.7367C8.43173 13.2889 8.87944 13.7367 9.43173 13.7367H10.9386C12.3587 13.7367 13.4328 13.0991 14.1246 12.1883C14.7926 11.3086 15.0856 10.2062 15.0856 9.21676C15.0856 6.92612 13.0205 4.80456 9.6299 4.80456L4.32863 4.80456L5.98775 3.14543Z" 953 953 fill="currentColor" 954 954 /> 955 955 </svg> 956 956 ); 957 957 }; 958 958 959 - // Text Toolbar Icons h:24px, w: variable 960 - 961 - export const UndoSmall = (props: Props) => { 959 + export const RedoTiny = (props: Props) => { 962 960 return ( 963 961 <svg 964 - width="24" 965 - height="24" 966 - viewBox="0 0 24 24" 962 + width="16" 963 + height="16" 964 + viewBox="0 0 16 16" 967 965 fill="none" 968 966 xmlns="http://www.w3.org/2000/svg" 969 967 {...props} ··· 971 969 <path 972 970 fillRule="evenodd" 973 971 clipRule="evenodd" 974 - d="M8.13949 5.17586C8.53001 4.78533 8.53001 4.15217 8.13949 3.76164C7.74897 3.37112 7.1158 3.37112 6.72528 3.76164L2.78076 7.70616L6.72528 11.6507C7.1158 12.0412 7.74897 12.0412 8.13949 11.6507C8.53001 11.2601 8.53001 10.627 8.13949 10.2365L6.60933 8.7063H14.1598C16.0894 8.7063 17.221 9.17118 17.8647 9.7593C18.4949 10.335 18.8046 11.1639 18.8046 12.2285C18.8046 13.2209 18.441 14.0478 17.707 14.6471C16.9511 15.2643 15.6989 15.7221 13.7911 15.7221H9.42379C8.87151 15.7221 8.42379 16.1698 8.42379 16.7221C8.42379 17.2743 8.87151 17.7221 9.42379 17.7221H13.7911C15.9944 17.7221 17.7489 17.1949 18.9719 16.1962C20.217 15.1796 20.8046 13.7597 20.8046 12.2285C20.8046 10.7694 20.3679 9.33716 19.2137 8.28273C18.0731 7.24068 16.3823 6.7063 14.1598 6.7063H6.60905L8.13949 5.17586Z" 972 + d="M10.0122 3.14543C9.62172 2.75491 9.62172 2.12174 10.0122 1.73122C10.4028 1.34069 11.0359 1.34069 11.4265 1.73122L14.7927 5.09744C15.1832 5.48796 15.1832 6.12113 14.7927 6.51165L11.4265 9.87787C11.0359 10.2684 10.4028 10.2684 10.0122 9.87787C9.62172 9.48735 9.62172 8.85418 10.0122 8.46366L11.6713 6.80456H6.3701C3.82678 6.80456 2.91443 8.27148 2.91443 9.21676C2.91443 9.84525 3.10681 10.5028 3.46817 10.9786C3.8058 11.4232 4.30523 11.7367 5.06143 11.7367H6.56827C7.12056 11.7367 7.56827 12.1844 7.56827 12.7367C7.56827 13.2889 7.12056 13.7367 6.56827 13.7367H5.06143C3.6413 13.7367 2.56723 13.0991 1.87544 12.1883C1.20738 11.3086 0.914429 10.2062 0.914429 9.21676C0.914429 6.92612 2.97946 4.80456 6.3701 4.80456L11.6714 4.80456L10.0122 3.14543Z" 975 973 fill="currentColor" 976 974 /> 977 975 </svg> 978 976 ); 979 977 }; 980 978 981 - export const RedoSmall = (props: Props) => { 979 + export const SearchTiny = (props: Props) => { 982 980 return ( 983 981 <svg 984 - width="24" 985 - height="24" 986 - viewBox="0 0 24 24" 982 + width="16" 983 + height="16" 984 + viewBox="0 0 16 16" 987 985 fill="none" 988 986 xmlns="http://www.w3.org/2000/svg" 989 987 {...props} ··· 991 989 <path 992 990 fillRule="evenodd" 993 991 clipRule="evenodd" 994 - d="M15.8605 5.17586C15.47 4.78533 15.47 4.15217 15.8605 3.76164C16.251 3.37112 16.8842 3.37112 17.2747 3.76164L21.2192 7.70616L17.2747 11.6507C16.8842 12.0412 16.251 12.0412 15.8605 11.6507C15.47 11.2601 15.47 10.627 15.8605 10.2365L17.3907 8.7063H9.84018C7.9106 8.7063 6.77903 9.17118 6.13528 9.7593C5.50508 10.335 5.19542 11.1639 5.19542 12.2285C5.19542 13.2209 5.55903 14.0478 6.29299 14.6471C7.04893 15.2643 8.30115 15.7221 10.2089 15.7221H14.5762C15.1285 15.7221 15.5762 16.1698 15.5762 16.7221C15.5762 17.2743 15.1285 17.7221 14.5762 17.7221H10.2089C8.00564 17.7221 6.25111 17.1949 5.02806 16.1962C3.78304 15.1796 3.19542 13.7597 3.19542 12.2285C3.19542 10.7694 3.63213 9.33716 4.7863 8.28273C5.92692 7.24068 7.61773 6.7063 9.84018 6.7063H17.391L15.8605 5.17586Z" 992 + d="M2.95685 7.38903C2.5496 5.40568 3.7763 3.51814 5.64053 3.13535C7.50476 2.75255 9.37599 4.00398 9.78324 5.98733C10.1905 7.97069 8.9638 9.85822 7.09957 10.241C5.23534 10.6238 3.36411 9.37239 2.95685 7.38903ZM5.36899 1.81294C2.73276 2.35425 1.08592 4.98923 1.63444 7.66057C2.18297 10.3319 4.73488 12.1047 7.37111 11.5634C8.07196 11.4195 8.70288 11.1276 9.24122 10.7264L13.1848 13.9403C13.613 14.2892 14.2429 14.225 14.5918 13.7969C14.9407 13.3688 14.8764 12.7389 14.4483 12.39L10.5619 9.2227C11.1395 8.20102 11.3616 6.96237 11.1057 5.7158C10.5571 3.04445 8.00522 1.27163 5.36899 1.81294ZM4.83423 6.30333C5.22704 6.22267 5.48009 5.83885 5.39944 5.44604C5.31878 5.05322 4.93496 4.80017 4.54214 4.88083C4.14933 4.96149 3.89628 5.34531 3.97694 5.73812C4.0576 6.13094 4.44142 6.38399 4.83423 6.30333ZM5.22415 7.40592C5.1635 7.06611 4.83887 6.83981 4.49906 6.90045C4.15925 6.9611 3.93295 7.28573 3.99359 7.62554C4.1788 8.66325 5.33436 9.59619 6.85483 9.38256C7.19665 9.33454 7.43481 9.0185 7.38679 8.67668C7.33876 8.33486 7.02273 8.09669 6.68091 8.14472C5.74535 8.27617 5.27738 7.70416 5.22415 7.40592Z" 995 993 fill="currentColor" 996 994 /> 997 995 </svg> 998 996 ); 999 997 }; 998 + 999 + // Text Toolbar Icons h:24px, w: variable 1000 1000 1001 1001 export const BoldSmall = (props: Props) => { 1002 1002 return (
+66 -12
components/Pages/index.tsx
··· 26 26 import { DraftPostOptions } from "../Blocks/MailboxBlock"; 27 27 import { Blocks } from "components/Blocks"; 28 28 import { MenuItem, Menu } from "../Layout"; 29 - import { MoreOptionsTiny, CloseTiny, PaintSmall, ShareSmall } from "../Icons"; 29 + import { 30 + MoreOptionsTiny, 31 + CloseTiny, 32 + PaintSmall, 33 + ShareSmall, 34 + UndoTiny, 35 + RedoTiny, 36 + } from "../Icons"; 30 37 import { HelpPopover } from "../HelpPopover"; 31 38 import { scanIndex } from "src/replicache/utils"; 32 39 import { PageThemeSetter } from "../ThemeManager/PageThemeSetter"; ··· 35 42 import { Watermark } from "components/Watermark"; 36 43 import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 37 44 import { LoginButton } from "components/LoginButton"; 45 + import { useUndoState } from "src/undoManager"; 46 + import { useIsMobile } from "src/hooks/isMobile"; 38 47 39 48 export function Pages(props: { rootPage: string }) { 40 49 let rootPage = useEntity(props.rootPage, "root/page")[0]; ··· 158 167 `} 159 168 > 160 169 <Media mobile={true}> 161 - <PageOptionsMenu entityID={props.entityID} first={props.first} /> 170 + <PageOptions entityID={props.entityID} first={props.first} /> 162 171 </Media> 163 172 <DesktopPageFooter pageID={props.entityID} /> 164 173 {isDraft.length > 0 && ( ··· 177 186 </div> 178 187 <Media mobile={false}> 179 188 {isFocused && ( 180 - <PageOptionsMenu entityID={props.entityID} first={props.first} /> 189 + <PageOptions entityID={props.entityID} first={props.first} /> 181 190 )} 182 191 </Media> 183 192 </div> ··· 242 251 ); 243 252 }; 244 253 245 - const PageOptionsMenu = (props: { 254 + let greyButtonStyle = 255 + "pt-[2px] h-5 w-5 p-0.5 mx-auto bg-border text-bg-page sm:rounded-r-md sm:rounded-l-none rounded-b-md hover:bg-accent-1 hover:text-accent-2"; 256 + let whiteButtonStyle = ` 257 + pageOptionsTrigger 258 + shrink-0 259 + bg-bg-page text-border 260 + outline-none border sm:border-l-0 border-t-1 border-border sm:rounded-r-md sm:rounded-l-none rounded-b-md 261 + hover:shadow-[0_1px_0_theme(colors.border)_inset,_0_-1px_0_theme(colors.border)_inset,_-1px_0_0_theme(colors.border)_inset] 262 + flex items-center justify-center`; 263 + const PageOptions = (props: { 246 264 entityID: string; 247 265 first: boolean | undefined; 248 266 }) => { ··· 250 268 <div className=" z-10 w-fit absolute sm:top-3 sm:-right-[19px] top-0 right-3 flex sm:flex-col flex-row-reverse gap-1 items-start"> 251 269 {!props.first && ( 252 270 <button 253 - className="pt-[2px] h-5 w-5 p-0.5 mx-auto bg-border text-bg-page sm:rounded-r-md sm:rounded-l-none rounded-b-md hover:bg-accent-1 hover:text-accent-2" 271 + className={greyButtonStyle} 254 272 onClick={() => { 255 273 useUIState.getState().closePage(props.entityID); 256 274 }} ··· 258 276 <CloseTiny /> 259 277 </button> 260 278 )} 261 - {<OptionsMenu entityID={props.entityID} first={!!props.first} />} 279 + <OptionsMenu 280 + entityID={props.entityID} 281 + first={!!props.first} 282 + buttonStyle={whiteButtonStyle} 283 + /> 284 + <UndoButtons /> 262 285 </div> 263 286 ); 264 287 }; 265 288 266 - const OptionsMenu = (props: { entityID: string; first: boolean }) => { 289 + const UndoButtons = () => { 290 + let undoState = useUndoState(); 291 + let { undoManager } = useReplicache(); 292 + return ( 293 + <Media mobile> 294 + <div className="gap-1 flex sm:flex-col"> 295 + {undoState.canUndo && ( 296 + <button 297 + className={`${whiteButtonStyle} h-5 w-5 p-0.5`} 298 + onClick={() => undoManager.undo()} 299 + > 300 + <UndoTiny /> 301 + </button> 302 + )} 303 + {undoState.canRedo ? ( 304 + <button 305 + className={`${whiteButtonStyle} h-5 w-5 p-0.5`} 306 + onClick={() => undoManager.undo()} 307 + > 308 + <RedoTiny /> 309 + </button> 310 + ) : ( 311 + <div className="h-5 w-5 p-0.5" /> 312 + )} 313 + </div> 314 + </Media> 315 + ); 316 + }; 317 + 318 + const OptionsMenu = (props: { 319 + entityID: string; 320 + first: boolean; 321 + buttonStyle: string; 322 + }) => { 267 323 let [state, setState] = useState<"normal" | "theme" | "share">("normal"); 268 324 let { permissions } = useEntitySetContext(); 269 325 if (!permissions.write) return null; ··· 276 332 trigger={ 277 333 <div 278 334 className={`pageOptionsTrigger 279 - shrink-0 sm:h-8 sm:w-5 h-5 w-8 280 - bg-bg-page text-border 281 - outline-none border sm:border-l-0 border-t-1 border-border sm:rounded-r-md sm:rounded-l-none rounded-b-md 282 - hover:shadow-[0_1px_0_theme(colors.border)_inset,_0_-1px_0_theme(colors.border)_inset,_-1px_0_0_theme(colors.border)_inset] 283 - flex items-center justify-center`} 335 + ${props.buttonStyle} 336 + sm:h-8 sm:w-5 h-5 w-8 337 + `} 284 338 > 285 339 <MoreOptionsTiny className="sm:rotate-90" /> 286 340 </div>
+379 -362
components/SelectionManager.tsx
··· 27 27 export function SelectionManager() { 28 28 let moreThanOneSelected = useUIState((s) => s.selectedBlocks.length > 1); 29 29 let entity_set = useEntitySetContext(); 30 - let { rep } = useReplicache(); 30 + let { rep, undoManager } = useReplicache(); 31 31 let isMobile = useIsMobile(); 32 32 useEffect(() => { 33 33 if (!entity_set.permissions.write) return; ··· 68 68 sortedBlocksWithChildren, 69 69 ]; 70 70 }; 71 - let removeListener = addShortcut([ 72 - { 73 - metaKey: true, 74 - key: "ArrowUp", 75 - handler: async () => { 76 - let [firstBlock] = 77 - (await rep?.query((tx) => 78 - getBlocksWithType( 79 - tx, 80 - useUIState.getState().selectedBlocks[0].parent, 81 - ), 82 - )) || []; 83 - if (firstBlock) focusBlock(firstBlock, { type: "start" }); 71 + let removeListener = addShortcut( 72 + [ 73 + { 74 + metaKey: true, 75 + key: "ArrowUp", 76 + handler: async () => { 77 + let [firstBlock] = 78 + (await rep?.query((tx) => 79 + getBlocksWithType( 80 + tx, 81 + useUIState.getState().selectedBlocks[0].parent, 82 + ), 83 + )) || []; 84 + if (firstBlock) focusBlock(firstBlock, { type: "start" }); 85 + }, 84 86 }, 85 - }, 86 - { 87 - metaKey: true, 88 - key: "ArrowDown", 89 - handler: async () => { 90 - let blocks = 91 - (await rep?.query((tx) => 92 - getBlocksWithType( 93 - tx, 94 - useUIState.getState().selectedBlocks[0].parent, 95 - ), 96 - )) || []; 97 - let folded = useUIState.getState().foldedBlocks; 98 - blocks = blocks.filter( 99 - (f) => 100 - !f.listData || 101 - !f.listData.path.find( 102 - (path) => 103 - folded.includes(path.entity) && f.value !== path.entity, 104 - ), 105 - ); 106 - let lastBlock = blocks[blocks.length - 1]; 107 - if (lastBlock) focusBlock(lastBlock, { type: "end" }); 87 + { 88 + metaKey: true, 89 + key: "ArrowDown", 90 + handler: async () => { 91 + let blocks = 92 + (await rep?.query((tx) => 93 + getBlocksWithType( 94 + tx, 95 + useUIState.getState().selectedBlocks[0].parent, 96 + ), 97 + )) || []; 98 + let folded = useUIState.getState().foldedBlocks; 99 + blocks = blocks.filter( 100 + (f) => 101 + !f.listData || 102 + !f.listData.path.find( 103 + (path) => 104 + folded.includes(path.entity) && f.value !== path.entity, 105 + ), 106 + ); 107 + let lastBlock = blocks[blocks.length - 1]; 108 + if (lastBlock) focusBlock(lastBlock, { type: "end" }); 109 + }, 108 110 }, 109 - }, 110 - { 111 - metaKey: true, 112 - altKey: true, 113 - key: ["l", "¬"], 114 - handler: async () => { 115 - let [sortedBlocks, siblings] = await getSortedSelection(); 116 - for (let block of sortedBlocks) { 117 - if (!block.listData) { 118 - await rep?.mutate.assertFact({ 119 - entity: block.value, 120 - attribute: "block/is-list", 121 - data: { type: "boolean", value: true }, 111 + { 112 + metaKey: true, 113 + altKey: true, 114 + key: ["l", "¬"], 115 + handler: async () => { 116 + let [sortedBlocks, siblings] = await getSortedSelection(); 117 + for (let block of sortedBlocks) { 118 + if (!block.listData) { 119 + await rep?.mutate.assertFact({ 120 + entity: block.value, 121 + attribute: "block/is-list", 122 + data: { type: "boolean", value: true }, 123 + }); 124 + } else { 125 + outdentFull(block, rep); 126 + } 127 + } 128 + }, 129 + }, 130 + { 131 + metaKey: true, 132 + shift: true, 133 + key: ["ArrowDown"], 134 + handler: async () => { 135 + let [sortedBlocks, siblings] = await getSortedSelection(); 136 + let block = sortedBlocks[0]; 137 + let nextBlock = siblings 138 + .slice(siblings.findIndex((s) => s.value === block.value) + 1) 139 + .find( 140 + (f) => 141 + f.listData && 142 + block.listData && 143 + !f.listData.path.find((f) => f.entity === block.value), 144 + ); 145 + if ( 146 + nextBlock?.listData && 147 + block.listData && 148 + nextBlock.listData.depth === block.listData.depth - 1 149 + ) { 150 + if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 151 + useUIState.getState().toggleFold(nextBlock.value); 152 + rep?.mutate.moveBlock({ 153 + block: block.value, 154 + oldParent: block.listData?.parent, 155 + newParent: nextBlock.value, 156 + position: { type: "first" }, 122 157 }); 123 158 } else { 124 - outdentFull(block, rep); 159 + rep?.mutate.moveBlockDown({ 160 + entityID: block.value, 161 + parent: block.listData?.parent || block.parent, 162 + }); 125 163 } 126 - } 127 - }, 128 - }, 129 - { 130 - metaKey: true, 131 - shift: true, 132 - key: ["ArrowDown"], 133 - handler: async () => { 134 - let [sortedBlocks, siblings] = await getSortedSelection(); 135 - let block = sortedBlocks[0]; 136 - let nextBlock = siblings 137 - .slice(siblings.findIndex((s) => s.value === block.value) + 1) 138 - .find( 139 - (f) => 140 - f.listData && 141 - block.listData && 142 - !f.listData.path.find((f) => f.entity === block.value), 143 - ); 144 - if ( 145 - nextBlock?.listData && 146 - block.listData && 147 - nextBlock.listData.depth === block.listData.depth - 1 148 - ) { 149 - if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 150 - useUIState.getState().toggleFold(nextBlock.value); 151 - rep?.mutate.moveBlock({ 152 - block: block.value, 153 - oldParent: block.listData?.parent, 154 - newParent: nextBlock.value, 155 - position: { type: "first" }, 156 - }); 157 - } else { 158 - rep?.mutate.moveBlockDown({ 159 - entityID: block.value, 160 - parent: block.listData?.parent || block.parent, 161 - }); 162 - } 164 + }, 163 165 }, 164 - }, 165 - { 166 - metaKey: true, 167 - shift: true, 168 - key: ["ArrowUp"], 169 - handler: async () => { 170 - let [sortedBlocks, siblings] = await getSortedSelection(); 171 - let block = sortedBlocks[0]; 172 - let previousBlock = 173 - siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 174 - if (previousBlock.value === block.listData?.parent) { 175 - previousBlock = 166 + { 167 + metaKey: true, 168 + shift: true, 169 + key: ["ArrowUp"], 170 + handler: async () => { 171 + let [sortedBlocks, siblings] = await getSortedSelection(); 172 + let block = sortedBlocks[0]; 173 + let previousBlock = 176 174 siblings?.[ 177 - siblings.findIndex((s) => s.value === block.value) - 2 175 + siblings.findIndex((s) => s.value === block.value) - 1 178 176 ]; 179 - } 177 + if (previousBlock.value === block.listData?.parent) { 178 + previousBlock = 179 + siblings?.[ 180 + siblings.findIndex((s) => s.value === block.value) - 2 181 + ]; 182 + } 180 183 181 - if ( 182 - previousBlock?.listData && 183 - block.listData && 184 - block.listData.depth > 1 && 185 - !previousBlock.listData.path.find( 186 - (f) => f.entity === block.listData?.parent, 187 - ) 188 - ) { 189 - let depth = block.listData.depth; 190 - let newParent = previousBlock.listData.path.find( 191 - (f) => f.depth === depth - 1, 192 - ); 193 - if (!newParent) return; 194 - if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 195 - useUIState.getState().toggleFold(newParent.entity); 196 - rep?.mutate.moveBlock({ 197 - block: block.value, 198 - oldParent: block.listData?.parent, 199 - newParent: newParent.entity, 200 - position: { type: "end" }, 201 - }); 202 - } else { 203 - rep?.mutate.moveBlockUp({ 204 - entityID: block.value, 205 - parent: block.listData?.parent || block.parent, 206 - }); 207 - } 184 + if ( 185 + previousBlock?.listData && 186 + block.listData && 187 + block.listData.depth > 1 && 188 + !previousBlock.listData.path.find( 189 + (f) => f.entity === block.listData?.parent, 190 + ) 191 + ) { 192 + let depth = block.listData.depth; 193 + let newParent = previousBlock.listData.path.find( 194 + (f) => f.depth === depth - 1, 195 + ); 196 + if (!newParent) return; 197 + if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 198 + useUIState.getState().toggleFold(newParent.entity); 199 + rep?.mutate.moveBlock({ 200 + block: block.value, 201 + oldParent: block.listData?.parent, 202 + newParent: newParent.entity, 203 + position: { type: "end" }, 204 + }); 205 + } else { 206 + rep?.mutate.moveBlockUp({ 207 + entityID: block.value, 208 + parent: block.listData?.parent || block.parent, 209 + }); 210 + } 211 + }, 208 212 }, 209 - }, 210 - { 211 - metaKey: true, 212 - shift: true, 213 - key: "Enter", 214 - handler: async () => { 215 - let [sortedBlocks, siblings] = await getSortedSelection(); 216 - if (!sortedBlocks[0].listData) return; 217 - useUIState.getState().toggleFold(sortedBlocks[0].value); 213 + { 214 + metaKey: true, 215 + shift: true, 216 + key: "Enter", 217 + handler: async () => { 218 + let [sortedBlocks, siblings] = await getSortedSelection(); 219 + if (!sortedBlocks[0].listData) return; 220 + useUIState.getState().toggleFold(sortedBlocks[0].value); 221 + }, 218 222 }, 219 - }, 220 - ]); 221 - let listener = async (e: KeyboardEvent) => { 222 - if (e.key === "Backspace" || e.key === "Delete") { 223 - if (!entity_set.permissions.write) return; 224 - if (moreThanOneSelected) { 225 - e.preventDefault(); 226 - let [sortedBlocks, siblings] = await getSortedSelection(); 227 - let selectedBlocks = useUIState.getState().selectedBlocks; 228 - let firstBlock = sortedBlocks[0]; 223 + ].map((shortcut) => ({ 224 + ...shortcut, 225 + handler: () => undoManager.withUndoGroup(() => shortcut.handler()), 226 + })), 227 + ); 228 + let listener = async (e: KeyboardEvent) => 229 + undoManager.withUndoGroup(async () => { 230 + if (e.key === "Backspace" || e.key === "Delete") { 231 + if (!entity_set.permissions.write) return; 232 + if (moreThanOneSelected) { 233 + e.preventDefault(); 234 + let [sortedBlocks, siblings] = await getSortedSelection(); 235 + let selectedBlocks = useUIState.getState().selectedBlocks; 236 + let firstBlock = sortedBlocks[0]; 229 237 230 - await rep?.mutate.removeBlock( 231 - selectedBlocks.map((block) => ({ blockEntity: block.value })), 232 - ); 233 - useUIState.getState().closePage(selectedBlocks.map((b) => b.value)); 238 + await rep?.mutate.removeBlock( 239 + selectedBlocks.map((block) => ({ blockEntity: block.value })), 240 + ); 241 + useUIState.getState().closePage(selectedBlocks.map((b) => b.value)); 234 242 235 - let nextBlock = 236 - siblings?.[ 237 - siblings.findIndex((s) => s.value === firstBlock.value) - 1 238 - ]; 239 - if (nextBlock) { 240 - useUIState.getState().setSelectedBlock({ 241 - value: nextBlock.value, 242 - parent: nextBlock.parent, 243 - }); 243 + let nextBlock = 244 + siblings?.[ 245 + siblings.findIndex((s) => s.value === firstBlock.value) - 1 246 + ]; 247 + if (nextBlock) { 248 + useUIState.getState().setSelectedBlock({ 249 + value: nextBlock.value, 250 + parent: nextBlock.parent, 251 + }); 252 + let type = await rep?.query((tx) => 253 + scanIndex(tx).eav(nextBlock.value, "block/type"), 254 + ); 255 + if (!type?.[0]) return; 256 + if ( 257 + type[0]?.data.value === "text" || 258 + type[0]?.data.value === "heading" 259 + ) 260 + focusBlock( 261 + { 262 + value: nextBlock.value, 263 + type: "text", 264 + parent: nextBlock.parent, 265 + }, 266 + { type: "end" }, 267 + ); 268 + } 269 + } 270 + } 271 + if (e.key === "ArrowUp") { 272 + let [sortedBlocks, siblings] = await getSortedSelection(); 273 + let focusedBlock = useUIState.getState().focusedEntity; 274 + if (!e.shiftKey && !e.ctrlKey) { 275 + if (e.defaultPrevented) return; 276 + if (sortedBlocks.length === 1) return; 277 + let firstBlock = sortedBlocks[0]; 278 + if (!firstBlock) return; 244 279 let type = await rep?.query((tx) => 245 - scanIndex(tx).eav(nextBlock.value, "block/type"), 280 + scanIndex(tx).eav(firstBlock.value, "block/type"), 246 281 ); 247 282 if (!type?.[0]) return; 283 + useUIState.getState().setSelectedBlock(firstBlock); 284 + focusBlock( 285 + { ...firstBlock, type: type[0].data.value }, 286 + { type: "start" }, 287 + ); 288 + } else { 289 + if (e.defaultPrevented) return; 248 290 if ( 249 - type[0]?.data.value === "text" || 250 - type[0]?.data.value === "heading" 291 + sortedBlocks.length <= 1 || 292 + !focusedBlock || 293 + focusedBlock.entityType === "page" 251 294 ) 252 - focusBlock( 253 - { 254 - value: nextBlock.value, 255 - type: "text", 256 - parent: nextBlock.parent, 257 - }, 258 - { type: "end" }, 295 + return; 296 + let b = focusedBlock; 297 + let focusedBlockIndex = sortedBlocks.findIndex( 298 + (s) => s.value == b.entityID, 299 + ); 300 + if (focusedBlockIndex === 0) { 301 + let index = siblings.findIndex((s) => s.value === b.entityID); 302 + let nextSelectedBlock = siblings[index - 1]; 303 + if (!nextSelectedBlock) return; 304 + 305 + scrollIntoViewIfNeeded( 306 + document.getElementById( 307 + elementId.block(nextSelectedBlock.value).container, 308 + ), 309 + false, 310 + ); 311 + useUIState.getState().addBlockToSelection({ 312 + ...nextSelectedBlock, 313 + }); 314 + useUIState.getState().setFocusedBlock({ 315 + entityType: "block", 316 + parent: nextSelectedBlock.parent, 317 + entityID: nextSelectedBlock.value, 318 + }); 319 + } else { 320 + let nextBlock = sortedBlocks[sortedBlocks.length - 2]; 321 + useUIState.getState().setFocusedBlock({ 322 + entityType: "block", 323 + parent: b.parent, 324 + entityID: nextBlock.value, 325 + }); 326 + scrollIntoViewIfNeeded( 327 + document.getElementById( 328 + elementId.block(nextBlock.value).container, 329 + ), 330 + false, 259 331 ); 332 + if (sortedBlocks.length === 2) { 333 + useEditorStates 334 + .getState() 335 + .editorStates[nextBlock.value]?.view?.focus(); 336 + } 337 + useUIState 338 + .getState() 339 + .removeBlockFromSelection(sortedBlocks[focusedBlockIndex]); 340 + } 260 341 } 261 342 } 262 - } 263 - if (e.key === "ArrowUp") { 264 - let [sortedBlocks, siblings] = await getSortedSelection(); 265 - let focusedBlock = useUIState.getState().focusedEntity; 266 - if (!e.shiftKey && !e.ctrlKey) { 267 - if (e.defaultPrevented) return; 268 - if (sortedBlocks.length === 1) return; 269 - let firstBlock = sortedBlocks[0]; 343 + if (e.key === "ArrowLeft") { 344 + let [sortedSelection, siblings] = await getSortedSelection(); 345 + if (sortedSelection.length === 1) return; 346 + let firstBlock = sortedSelection[0]; 270 347 if (!firstBlock) return; 271 348 let type = await rep?.query((tx) => 272 349 scanIndex(tx).eav(firstBlock.value, "block/type"), ··· 277 354 { ...firstBlock, type: type[0].data.value }, 278 355 { type: "start" }, 279 356 ); 280 - } else { 281 - if (e.defaultPrevented) return; 282 - if ( 283 - sortedBlocks.length <= 1 || 284 - !focusedBlock || 285 - focusedBlock.entityType === "page" 286 - ) 287 - return; 288 - let b = focusedBlock; 289 - let focusedBlockIndex = sortedBlocks.findIndex( 290 - (s) => s.value == b.entityID, 291 - ); 292 - if (focusedBlockIndex === 0) { 293 - let index = siblings.findIndex((s) => s.value === b.entityID); 294 - let nextSelectedBlock = siblings[index - 1]; 295 - if (!nextSelectedBlock) return; 296 - 297 - scrollIntoViewIfNeeded( 298 - document.getElementById( 299 - elementId.block(nextSelectedBlock.value).container, 300 - ), 301 - false, 302 - ); 303 - useUIState.getState().addBlockToSelection({ 304 - ...nextSelectedBlock, 305 - }); 306 - useUIState.getState().setFocusedBlock({ 307 - entityType: "block", 308 - parent: nextSelectedBlock.parent, 309 - entityID: nextSelectedBlock.value, 310 - }); 311 - } else { 312 - let nextBlock = sortedBlocks[sortedBlocks.length - 2]; 313 - useUIState.getState().setFocusedBlock({ 314 - entityType: "block", 315 - parent: b.parent, 316 - entityID: nextBlock.value, 317 - }); 318 - scrollIntoViewIfNeeded( 319 - document.getElementById( 320 - elementId.block(nextBlock.value).container, 321 - ), 322 - false, 323 - ); 324 - if (sortedBlocks.length === 2) { 325 - useEditorStates 326 - .getState() 327 - .editorStates[nextBlock.value]?.view?.focus(); 328 - } 329 - useUIState 330 - .getState() 331 - .removeBlockFromSelection(sortedBlocks[focusedBlockIndex]); 332 - } 333 357 } 334 - } 335 - if (e.key === "ArrowLeft") { 336 - let [sortedSelection, siblings] = await getSortedSelection(); 337 - if (sortedSelection.length === 1) return; 338 - let firstBlock = sortedSelection[0]; 339 - if (!firstBlock) return; 340 - let type = await rep?.query((tx) => 341 - scanIndex(tx).eav(firstBlock.value, "block/type"), 342 - ); 343 - if (!type?.[0]) return; 344 - useUIState.getState().setSelectedBlock(firstBlock); 345 - focusBlock( 346 - { ...firstBlock, type: type[0].data.value }, 347 - { type: "start" }, 348 - ); 349 - } 350 - if (e.key === "ArrowRight") { 351 - let [sortedSelection, siblings] = await getSortedSelection(); 352 - if (sortedSelection.length === 1) return; 353 - let lastBlock = sortedSelection[sortedSelection.length - 1]; 354 - if (!lastBlock) return; 355 - let type = await rep?.query((tx) => 356 - scanIndex(tx).eav(lastBlock.value, "block/type"), 357 - ); 358 - if (!type?.[0]) return; 359 - useUIState.getState().setSelectedBlock(lastBlock); 360 - focusBlock({ ...lastBlock, type: type[0].data.value }, { type: "end" }); 361 - } 362 - if (e.key === "Tab") { 363 - let [sortedSelection, siblings] = await getSortedSelection(); 364 - if (sortedSelection.length <= 1) return; 365 - e.preventDefault(); 366 - if (e.shiftKey) { 367 - for (let i = siblings.length - 1; i >= 0; i--) { 368 - let block = siblings[i]; 369 - if (!sortedSelection.find((s) => s.value === block.value)) continue; 370 - if (sortedSelection.find((s) => s.value === block.listData?.parent)) 371 - continue; 372 - let parentoffset = 1; 373 - let previousBlock = siblings[i - parentoffset]; 374 - while ( 375 - previousBlock && 376 - sortedSelection.find((s) => previousBlock.value === s.value) 377 - ) { 378 - parentoffset += 1; 379 - previousBlock = siblings[i - parentoffset]; 380 - } 381 - if (!block.listData || !previousBlock.listData) continue; 382 - outdent(block, previousBlock, rep); 383 - } 384 - } else { 385 - for (let i = 0; i < siblings.length; i++) { 386 - let block = siblings[i]; 387 - if (!sortedSelection.find((s) => s.value === block.value)) continue; 388 - if (sortedSelection.find((s) => s.value === block.listData?.parent)) 389 - continue; 390 - let parentoffset = 1; 391 - let previousBlock = siblings[i - parentoffset]; 392 - while ( 393 - previousBlock && 394 - sortedSelection.find((s) => previousBlock.value === s.value) 395 - ) { 396 - parentoffset += 1; 397 - previousBlock = siblings[i - parentoffset]; 398 - } 399 - if (!block.listData || !previousBlock.listData) continue; 400 - indent(block, previousBlock, rep); 401 - } 402 - } 403 - } 404 - if (e.key === "ArrowDown") { 405 - let [sortedSelection, siblings] = await getSortedSelection(); 406 - let focusedBlock = useUIState.getState().focusedEntity; 407 - if (!e.shiftKey) { 358 + if (e.key === "ArrowRight") { 359 + let [sortedSelection, siblings] = await getSortedSelection(); 408 360 if (sortedSelection.length === 1) return; 409 361 let lastBlock = sortedSelection[sortedSelection.length - 1]; 410 362 if (!lastBlock) return; ··· 418 370 { type: "end" }, 419 371 ); 420 372 } 421 - if (e.shiftKey) { 422 - if (e.defaultPrevented) return; 423 - if ( 424 - sortedSelection.length <= 1 || 425 - !focusedBlock || 426 - focusedBlock.entityType === "page" 427 - ) 428 - return; 429 - let b = focusedBlock; 430 - let focusedBlockIndex = sortedSelection.findIndex( 431 - (s) => s.value == b.entityID, 432 - ); 433 - if (focusedBlockIndex === sortedSelection.length - 1) { 434 - let index = siblings.findIndex((s) => s.value === b.entityID); 435 - let nextSelectedBlock = siblings[index + 1]; 436 - if (!nextSelectedBlock) return; 437 - useUIState.getState().addBlockToSelection({ 438 - ...nextSelectedBlock, 439 - }); 440 - 441 - scrollIntoViewIfNeeded( 442 - document.getElementById( 443 - elementId.block(nextSelectedBlock.value).container, 444 - ), 445 - false, 373 + if (e.key === "Tab") { 374 + let [sortedSelection, siblings] = await getSortedSelection(); 375 + if (sortedSelection.length <= 1) return; 376 + e.preventDefault(); 377 + if (e.shiftKey) { 378 + for (let i = siblings.length - 1; i >= 0; i--) { 379 + let block = siblings[i]; 380 + if (!sortedSelection.find((s) => s.value === block.value)) 381 + continue; 382 + if ( 383 + sortedSelection.find((s) => s.value === block.listData?.parent) 384 + ) 385 + continue; 386 + let parentoffset = 1; 387 + let previousBlock = siblings[i - parentoffset]; 388 + while ( 389 + previousBlock && 390 + sortedSelection.find((s) => previousBlock.value === s.value) 391 + ) { 392 + parentoffset += 1; 393 + previousBlock = siblings[i - parentoffset]; 394 + } 395 + if (!block.listData || !previousBlock.listData) continue; 396 + outdent(block, previousBlock, rep); 397 + } 398 + } else { 399 + for (let i = 0; i < siblings.length; i++) { 400 + let block = siblings[i]; 401 + if (!sortedSelection.find((s) => s.value === block.value)) 402 + continue; 403 + if ( 404 + sortedSelection.find((s) => s.value === block.listData?.parent) 405 + ) 406 + continue; 407 + let parentoffset = 1; 408 + let previousBlock = siblings[i - parentoffset]; 409 + while ( 410 + previousBlock && 411 + sortedSelection.find((s) => previousBlock.value === s.value) 412 + ) { 413 + parentoffset += 1; 414 + previousBlock = siblings[i - parentoffset]; 415 + } 416 + if (!block.listData || !previousBlock.listData) continue; 417 + indent(block, previousBlock, rep); 418 + } 419 + } 420 + } 421 + if (e.key === "ArrowDown") { 422 + let [sortedSelection, siblings] = await getSortedSelection(); 423 + let focusedBlock = useUIState.getState().focusedEntity; 424 + if (!e.shiftKey) { 425 + if (sortedSelection.length === 1) return; 426 + let lastBlock = sortedSelection[sortedSelection.length - 1]; 427 + if (!lastBlock) return; 428 + let type = await rep?.query((tx) => 429 + scanIndex(tx).eav(lastBlock.value, "block/type"), 430 + ); 431 + if (!type?.[0]) return; 432 + useUIState.getState().setSelectedBlock(lastBlock); 433 + focusBlock( 434 + { ...lastBlock, type: type[0].data.value }, 435 + { type: "end" }, 446 436 ); 447 - useUIState.getState().setFocusedBlock({ 448 - entityType: "block", 449 - parent: nextSelectedBlock.parent, 450 - entityID: nextSelectedBlock.value, 451 - }); 452 - } else { 453 - let nextBlock = sortedSelection[1]; 454 - useUIState 455 - .getState() 456 - .removeBlockFromSelection({ value: b.entityID }); 457 - scrollIntoViewIfNeeded( 458 - document.getElementById( 459 - elementId.block(nextBlock.value).container, 460 - ), 461 - false, 437 + } 438 + if (e.shiftKey) { 439 + if (e.defaultPrevented) return; 440 + if ( 441 + sortedSelection.length <= 1 || 442 + !focusedBlock || 443 + focusedBlock.entityType === "page" 444 + ) 445 + return; 446 + let b = focusedBlock; 447 + let focusedBlockIndex = sortedSelection.findIndex( 448 + (s) => s.value == b.entityID, 462 449 ); 463 - useUIState.getState().setFocusedBlock({ 464 - entityType: "block", 465 - parent: b.parent, 466 - entityID: nextBlock.value, 467 - }); 468 - if (sortedSelection.length === 2) { 469 - useEditorStates 450 + if (focusedBlockIndex === sortedSelection.length - 1) { 451 + let index = siblings.findIndex((s) => s.value === b.entityID); 452 + let nextSelectedBlock = siblings[index + 1]; 453 + if (!nextSelectedBlock) return; 454 + useUIState.getState().addBlockToSelection({ 455 + ...nextSelectedBlock, 456 + }); 457 + 458 + scrollIntoViewIfNeeded( 459 + document.getElementById( 460 + elementId.block(nextSelectedBlock.value).container, 461 + ), 462 + false, 463 + ); 464 + useUIState.getState().setFocusedBlock({ 465 + entityType: "block", 466 + parent: nextSelectedBlock.parent, 467 + entityID: nextSelectedBlock.value, 468 + }); 469 + } else { 470 + let nextBlock = sortedSelection[1]; 471 + useUIState 470 472 .getState() 471 - .editorStates[nextBlock.value]?.view?.focus(); 473 + .removeBlockFromSelection({ value: b.entityID }); 474 + scrollIntoViewIfNeeded( 475 + document.getElementById( 476 + elementId.block(nextBlock.value).container, 477 + ), 478 + false, 479 + ); 480 + useUIState.getState().setFocusedBlock({ 481 + entityType: "block", 482 + parent: b.parent, 483 + entityID: nextBlock.value, 484 + }); 485 + if (sortedSelection.length === 2) { 486 + useEditorStates 487 + .getState() 488 + .editorStates[nextBlock.value]?.view?.focus(); 489 + } 472 490 } 473 491 } 474 492 } 475 - } 476 - if (e.key === "c" && (e.metaKey || e.ctrlKey)) { 477 - if (!rep) return; 478 - let [, , selectionWithFoldedChildren] = await getSortedSelection(); 479 - if (!selectionWithFoldedChildren) return; 480 - await copySelection(rep, selectionWithFoldedChildren); 481 - } 482 - }; 493 + if (e.key === "c" && (e.metaKey || e.ctrlKey)) { 494 + if (!rep) return; 495 + let [, , selectionWithFoldedChildren] = await getSortedSelection(); 496 + if (!selectionWithFoldedChildren) return; 497 + await copySelection(rep, selectionWithFoldedChildren); 498 + } 499 + }); 483 500 window.addEventListener("keydown", listener); 484 501 return () => { 485 502 removeListener();
+22 -1
components/Toolbar/TextBlockTypeToolbar.tsx
··· 170 170 }; 171 171 172 172 export function keepFocus(entityID: string) { 173 - setTimeout(() => {}, 1000); 173 + let existingEditor = useEditorStates.getState().editorStates[entityID]; 174 + 175 + let selection = existingEditor?.editor.selection; 176 + 177 + setTimeout(() => { 178 + let existingEditor = useEditorStates.getState().editorStates[entityID]; 179 + 180 + if (!existingEditor) return; 181 + 182 + existingEditor.view?.focus(); 183 + 184 + setEditorState(entityID, { 185 + editor: existingEditor.editor.apply( 186 + existingEditor.editor.tr.setSelection( 187 + TextSelection.create( 188 + existingEditor.editor.doc, 189 + selection?.anchor || 1, 190 + ), 191 + ), 192 + ), 193 + }); 194 + }, 50); 174 195 } 175 196 176 197 export function TextBlockTypeButton(props: {
+6
package-lock.json
··· 19 19 "@radix-ui/react-tooltip": "^1.1.2", 20 20 "@react-aria/utils": "^3.24.1", 21 21 "@react-spring/web": "^9.7.3", 22 + "@rocicorp/undo": "^0.2.1", 22 23 "@supabase/ssr": "^0.3.0", 23 24 "@supabase/supabase-js": "^2.43.2", 24 25 "@types/mdx": "^2.0.13", ··· 5228 5229 "engines": { 5229 5230 "node": "^12.20.0 || ^14.13.1 || >=16.0.0" 5230 5231 } 5232 + }, 5233 + "node_modules/@rocicorp/undo": { 5234 + "version": "0.2.1", 5235 + "resolved": "https://registry.npmjs.org/@rocicorp/undo/-/undo-0.2.1.tgz", 5236 + "integrity": "sha512-m4hPbRPFI/3XEzCJdyZbT1JY+3+SV+WZQXiujyDr7gynsNFuNE3tr95f1arCHLuCJVonwL8OE8mQTVEwjD8lDg==" 5231 5237 }, 5232 5238 "node_modules/@rushstack/eslint-patch": { 5233 5239 "version": "1.10.3",
+1
package.json
··· 21 21 "@radix-ui/react-tooltip": "^1.1.2", 22 22 "@react-aria/utils": "^3.24.1", 23 23 "@react-spring/web": "^9.7.3", 24 + "@rocicorp/undo": "^0.2.1", 24 25 "@supabase/ssr": "^0.3.0", 25 26 "@supabase/supabase-js": "^2.43.2", 26 27 "@types/mdx": "^2.0.13",
+83 -8
src/replicache/clientMutationContext.ts
··· 1 - import { WriteTransaction } from "replicache"; 1 + import { Replicache, WriteTransaction } from "replicache"; 2 2 import * as Y from "yjs"; 3 3 import * as base64 from "base64-js"; 4 4 import { FactWithIndexes, scanIndex } from "./utils"; 5 5 import { Attributes, FilterAttributes } from "./attributes"; 6 - import { Fact } from "."; 7 - import { MutationContext } from "./mutations"; 6 + import { Fact, ReplicacheMutators } from "."; 7 + import { FactInput, MutationContext } from "./mutations"; 8 8 import { supabaseBrowserClient } from "supabase/browserClient"; 9 9 import { v7 } from "uuid"; 10 + import { UndoManager } from "src/undoManager"; 10 11 11 - export function clientMutationContext(tx: WriteTransaction) { 12 + export function clientMutationContext( 13 + tx: WriteTransaction, 14 + { 15 + rep, 16 + undoManager, 17 + ignoreUndo, 18 + defaultEntitySet, 19 + }: { 20 + undoManager: UndoManager; 21 + rep: Replicache<ReplicacheMutators>; 22 + ignoreUndo: boolean; 23 + defaultEntitySet: string; 24 + }, 25 + ) { 12 26 let ctx: MutationContext = { 13 27 async runOnServer(cb) {}, 14 28 async runOnClient(cb) { ··· 29 43 if (!attribute) return; 30 44 let id = f.id || v7(); 31 45 let data = { ...f.data }; 46 + let existingFact = [] as Fact<any>[]; 32 47 if (attribute.cardinality === "one") { 33 - let existingFact = await scanIndex(tx).eav(f.entity, f.attribute); 48 + existingFact = await scanIndex(tx).eav(f.entity, f.attribute); 34 49 if (existingFact[0]) { 35 50 id = existingFact[0].id; 36 51 if (attribute.type === "text") { ··· 50 65 } 51 66 } 52 67 } 68 + if (!ignoreUndo) 69 + undoManager.add({ 70 + undo: () => { 71 + if (existingFact[0]) { 72 + rep.mutate.assertFact({ ignoreUndo: true, ...existingFact[0] }); 73 + } else { 74 + if (attribute.cardinality === "one" && !f.id) 75 + rep.mutate.retractAttribute({ 76 + ignoreUndo: true, 77 + attribute: f.attribute as keyof FilterAttributes<{ 78 + cardinality: "one"; 79 + }>, 80 + entity: f.entity, 81 + }); 82 + rep.mutate.retractFact({ ignoreUndo: true, factID: id }); 83 + } 84 + }, 85 + redo: () => { 86 + rep.mutate.assertFact({ ignoreUndo: true, ...(f as Fact<any>) }); 87 + }, 88 + }); 53 89 await tx.set(id, FactWithIndexes({ id, ...f, data })); 54 90 }, 55 91 async retractFact(id) { 92 + let fact = await tx.get(id); 93 + if (!ignoreUndo) 94 + undoManager.add({ 95 + undo: () => { 96 + if (fact) { 97 + rep.mutate.assertFact({ 98 + ignoreUndo: true, 99 + ...(fact as Fact<any>), 100 + }); 101 + } else { 102 + rep.mutate.retractFact({ ignoreUndo: true, factID: id }); 103 + } 104 + }, 105 + redo: () => { 106 + rep.mutate.assertFact({ ignoreUndo: true, ...(fact as Fact<any>) }); 107 + }, 108 + }); 56 109 await tx.del(id); 57 110 }, 58 111 async deleteEntity(entity) { ··· 68 121 prefix: entity, 69 122 }) 70 123 .toArray(); 71 - await Promise.all( 72 - [...existingFacts, ...references].map((f) => tx.del(f.id)), 73 - ); 124 + let facts = [...existingFacts, ...references]; 125 + await Promise.all(facts.map((f) => tx.del(f.id))); 126 + if (!ignoreUndo && facts.length > 0) { 127 + undoManager.add({ 128 + undo: async () => { 129 + let input: FactInput[] & { ignoreUndo?: true } = facts.map( 130 + (f) => 131 + ({ 132 + id: f.id, 133 + attribute: f.attribute, 134 + entity: f.entity, 135 + data: f.data, 136 + }) as FactInput, 137 + ); 138 + input.ignoreUndo = true; 139 + await rep.mutate.createEntity([ 140 + { entityID: entity, permission_set: defaultEntitySet }, 141 + ]); 142 + await rep.mutate.assertFact(input); 143 + }, 144 + redo: () => { 145 + rep.mutate.deleteEntity({ entity, ignoreUndo: true }); 146 + }, 147 + }); 148 + } 74 149 }, 75 150 }; 76 151 return ctx;
+49 -2
src/replicache/index.tsx
··· 1 1 "use client"; 2 - import { createContext, useContext, useEffect, useMemo, useState } from "react"; 2 + import { 3 + createContext, 4 + useCallback, 5 + useContext, 6 + useEffect, 7 + useMemo, 8 + useRef, 9 + useState, 10 + } from "react"; 3 11 import { useSubscribe } from "replicache-react"; 4 12 import { 5 13 DeepReadonlyObject, ··· 13 21 import { clientMutationContext } from "./clientMutationContext"; 14 22 import { supabaseBrowserClient } from "supabase/browserClient"; 15 23 import { callRPC } from "app/api/rpc/client"; 24 + import { UndoManager } from "@rocicorp/undo"; 25 + import { addShortcut } from "src/shortcuts"; 26 + import { createUndoManager } from "src/undoManager"; 16 27 17 28 export type Fact<A extends keyof typeof Attributes> = { 18 29 id: string; ··· 22 33 }; 23 34 24 35 let ReplicacheContext = createContext({ 36 + undoManager: createUndoManager(), 25 37 rootEntity: "" as string, 26 38 rep: null as null | Replicache<ReplicacheMutators>, 27 39 initialFacts: [] as Fact<keyof typeof Attributes>[], ··· 59 71 initialFactsOnly?: boolean; 60 72 }) { 61 73 let [rep, setRep] = useState<null | Replicache<ReplicacheMutators>>(null); 74 + let [undoManager] = useState(createUndoManager()); 75 + useEffect(() => { 76 + return addShortcut([ 77 + { 78 + metaKey: true, 79 + key: "z", 80 + handler: () => { 81 + undoManager.undo(); 82 + }, 83 + }, 84 + { 85 + metaKey: true, 86 + shift: true, 87 + key: "z", 88 + handler: () => { 89 + undoManager.redo(); 90 + }, 91 + }, 92 + { 93 + metaKey: true, 94 + shift: true, 95 + key: "Z", 96 + handler: () => { 97 + undoManager.redo(); 98 + }, 99 + }, 100 + ]); 101 + }, [undoManager]); 62 102 useEffect(() => { 63 103 if (props.initialFactsOnly) return; 64 104 let supabase = supabaseBrowserClient(); ··· 71 111 async (tx: WriteTransaction, args: any) => { 72 112 await mutations[m as keyof typeof mutations]( 73 113 args, 74 - clientMutationContext(tx), 114 + clientMutationContext(tx, { 115 + undoManager, 116 + rep: newRep, 117 + ignoreUndo: args.ignoreUndo || tx.reason !== "initial", 118 + defaultEntitySet: 119 + props.token.permission_token_rights[0]?.entity_set, 120 + }), 75 121 ); 76 122 }, 77 123 ]; ··· 127 173 return ( 128 174 <ReplicacheContext.Provider 129 175 value={{ 176 + undoManager, 130 177 rep, 131 178 rootEntity: props.rootEntity, 132 179 initialFacts: props.initialFacts,
+12 -4
src/replicache/mutations.ts
··· 1 - import { DeepReadonly } from "replicache"; 2 - import { Fact } from "."; 1 + import { DeepReadonly, Replicache } from "replicache"; 2 + import { Fact, ReplicacheMutators } from "."; 3 3 import { Attributes, FilterAttributes } from "./attributes"; 4 4 import { SupabaseClient } from "@supabase/supabase-js"; 5 5 import { Database } from "supabase/database.types"; ··· 29 29 ): Promise<void>; 30 30 }; 31 31 32 - type Mutation<T> = (args: T, ctx: MutationContext) => Promise<void>; 32 + type Mutation<T> = ( 33 + args: T & { ignoreUndo?: true }, 34 + ctx: MutationContext, 35 + ) => Promise<void>; 33 36 34 37 const addCanvasBlock: Mutation<{ 35 38 parent: string; ··· 324 327 } 325 328 }; 326 329 327 - type FactInput = { 330 + const deleteEntity: Mutation<{ entity: string }> = async (args, ctx) => { 331 + await ctx.deleteEntity(args.entity); 332 + }; 333 + 334 + export type FactInput = { 328 335 [k in keyof typeof Attributes]: Omit<Fact<k>, "id"> & { id?: string }; 329 336 }[keyof typeof Attributes]; 330 337 const assertFact: Mutation<FactInput | Array<FactInput>> = async ( ··· 610 617 assertFact, 611 618 retractFact, 612 619 removeBlock, 620 + deleteEntity, 613 621 moveChildren, 614 622 increaseHeadingLevel, 615 623 archiveDraft,
+41
src/undoManager.ts
··· 1 + import { UndoManager as RociUndoManager } from "@rocicorp/undo"; 2 + import { create } from "zustand"; 3 + 4 + export type UndoManager = ReturnType<typeof createUndoManager>; 5 + export const useUndoState = create(() => ({ canUndo: false, canRedo: false })); 6 + export const createUndoManager = () => { 7 + let isGrouping = false; 8 + let undoManager = new RociUndoManager({ 9 + onChange: (state) => { 10 + useUndoState.setState(state); 11 + }, 12 + }); 13 + let um = { 14 + add: (args: { 15 + undo: () => Promise<void> | void; 16 + redo: () => Promise<void> | void; 17 + }) => { 18 + undoManager.add(args); 19 + }, 20 + startGroup: (groupName?: string) => { 21 + if (isGrouping) { 22 + undoManager.endGroup(); 23 + } 24 + isGrouping = true; 25 + undoManager.startGroup(); 26 + }, 27 + endGroup: () => { 28 + isGrouping = false; 29 + undoManager.endGroup(); 30 + }, 31 + undo: () => undoManager.undo(), 32 + redo: () => undoManager.redo(), 33 + withUndoGroup: <T>(cb: () => T) => { 34 + if (!isGrouping) um.startGroup(); 35 + const r = cb(); 36 + if (!isGrouping) um.endGroup(); 37 + return r; 38 + }, 39 + }; 40 + return um; 41 + };