Skip to content

Commit

Permalink
c17: implement undo (#91)
Browse files Browse the repository at this point in the history
  • Loading branch information
jonfelixrico authored Apr 20, 2024
2 parents 88773b7 + 4b33058 commit 072e189
Show file tree
Hide file tree
Showing 9 changed files with 175 additions and 8 deletions.
11 changes: 11 additions & 0 deletions client/cypress/e2e/pad-socket.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,17 @@ describe('pad-socket', () => {
cy.get(`[data-path-id=${pathId}]`)
.findCy('rendered-path')
.should('have.attr', 'data-points-length', 50)

cy.wrap({
delete: () => {
sendPadMessage({
PATH_DELETE: {
id: pathId,
},
})
},
}).invoke('delete')
cy.get(`[data-path-id=${pathId}]`).should('not.exist')
})

it('shows pre-existing drawings in the room on join/rejoin', async () => {
Expand Down
4 changes: 4 additions & 0 deletions client/src/modules/pad-common/pad.slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ export const padSlice = createSlice({
delete state.draftPaths[payload]
},

removePath: (state, { payload }: PayloadAction<string>) => {
delete state.paths[payload]
},

setColor: (state, { payload }: PayloadAction<PathColor>) => {
state.options.color = payload
},
Expand Down
26 changes: 26 additions & 0 deletions client/src/modules/pad-common/undo-command-listener.hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useUndoStackService } from '@/modules/pad-common/undo-stack.context'
import { useCallback, useEffect } from 'react'

export default function useUndoCommandListener() {
const { undo, stack } = useUndoStackService()

const handleUndo = useCallback(
async ({ ctrlKey, key }: KeyboardEvent) => {
if (ctrlKey && key.toLowerCase() === 'z' && stack.length) {
console.debug('Executing undo...')
await undo()
console.log('Undo executed')
}
},
[undo, stack]
)

useEffect(() => {
const fnRef = handleUndo
window.addEventListener('keydown', fnRef)

return () => {
window.removeEventListener('keydown', fnRef)
}
}, [handleUndo])
}
81 changes: 81 additions & 0 deletions client/src/modules/pad-common/undo-stack.context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/* eslint-disable react-refresh/only-export-components */
import { ReactNode, createContext, useCallback, useContext } from 'react'
import { useImmer } from 'use-immer'
import appStore from '@/store'
import { Socket } from 'socket.io-client'
import { useRoomSocket } from '@/modules/socket/room-socket.hook'

/**
* The stuff provided here MUST be atomic. It shouldn't be reactive.
* If you provide a reactive value, you might end up having unexpected behaviors if the undo function
* tries to reference it down the room lifecycle (i.e. several rerenders/recompute happened then the undo
* function utilizes the reference of the service)
*/
interface UndoInjectables {
store: typeof appStore
socket: Socket
}

type UndoFn = (services: UndoInjectables) => Promise<void>

interface UndoStackService {
push(fn: UndoFn): void
undo(): Promise<void>
stack: UndoFn[]
}

const DUMMY_SERVICE: UndoStackService = {
push: () => {},
undo: async () => {},
stack: [],
}

const UndoStackContext = createContext(DUMMY_SERVICE)

export function UndoStackProvider({ children }: { children?: ReactNode }) {
const [stack, setStack] = useImmer<UndoFn[]>([])
const socket = useRoomSocket()

const push: UndoStackService['push'] = useCallback(
(undoFn) => {
setStack((stack) => {
stack.push(undoFn)
})
},
[setStack]
)

const undo: UndoStackService['undo'] = useCallback(async () => {
if (!stack.length) {
return
}

const top = stack[stack.length - 1]
setStack((stack) => {
stack.pop()
})

/*
* Socket is guaranteed to be render-safe, despite being provided via useRoomSocket.
* A room will use the same socket reference throughout its lifecycle.
*/
await top({ store: appStore, socket })
}, [stack, setStack, socket])

return (
<UndoStackContext.Provider
value={{
undo,
push,
stack,
}}
>
{children}
</UndoStackContext.Provider>
)
}

export function useUndoStackService() {
const service = useContext(UndoStackContext)
return service
}
26 changes: 24 additions & 2 deletions client/src/modules/pad-service/path-input-service-impl.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import {
selectThickness,
} from '@/modules/pad-common/pad.slice'
import { nanoid } from 'nanoid'
import { PadSocketCode } from '@/modules/pad-socket/pad-socket.types'
import {
PAD_SOCKET_EVENT,
PadRequest,
PadSocketCode,
} from '@/modules/pad-socket/pad-socket.types'
import { useUndoStackService } from '@/modules/pad-common/undo-stack.context'

export function usePathInputServiceImpl() {
const color = useAppSelector(selectColor)
Expand All @@ -19,6 +24,22 @@ export function usePathInputServiceImpl() {
const dispatch = useAppDispatch()
const draftRef = useRef<PathData | null>(null)

const { push } = useUndoStackService()
const createUndo = useCallback(
({ id }: { id: string }) => {
push(async ({ store, socket }) => {
store.dispatch(PadActions.removePath(id))

socket.emit(PAD_SOCKET_EVENT, {
PATH_DELETE: {
id,
},
} as PadRequest)
})
},
[push]
)

const handleDraw = useCallback(
(event: DrawEvent) => {
if (event.isStart) {
Expand Down Expand Up @@ -59,14 +80,15 @@ export function usePathInputServiceImpl() {
sendMessage(PadSocketCode.PATH_CREATE, updated)
dispatch(PadActions.setPath(updated))
dispatch(PadActions.removeDraftPath(draft.id))
createUndo(updated)
} else {
// reaching this line means that we're processing regular move events
dispatch(PadActions.setDraftPath(updated))
}

draftRef.current = updated
},
[sendMessage, dispatch, color, thickness]
[sendMessage, dispatch, color, thickness, createUndo]
)

return useMemo(() => {
Expand Down
14 changes: 12 additions & 2 deletions client/src/modules/pad-socket/pad-socket.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export enum PadSocketCode {
PATH_DRAFT_START = 'PATH_DRAFT_START',
PATH_DRAFT_MOVE = 'PATH_DRAFT_MOVE',
PATH_CREATE = 'PATH_CREATE',
PATH_DELETE = 'PATH_DELETE',
}

export interface PathDraftStartPayload extends PathData {
Expand All @@ -30,5 +31,14 @@ interface PathCreate {
[PadSocketCode.PATH_CREATE]: PathCreatePayload
}

export type PadResponse = Partial<PathDraftStart & PathDraftMove & PathCreate>
export type PadRequest = PadResponse
export interface PathDeletePayload {
id: string
}
interface PathDelete {
[PadSocketCode.PATH_DELETE]: PathDeletePayload
}

export type PadResponse = Partial<
PathDraftStart & PathDraftMove & PathCreate & PathDelete
>
export type PadRequest = Partial<PadResponse & PathDelete>
14 changes: 11 additions & 3 deletions client/src/modules/pad-socket/socket-path-watcher.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
PathDraftMovePayload,
PathDraftStartPayload,
PadSocketCode,
PathDeletePayload,
} from '@/modules/pad-socket/pad-socket.types'
import { useMessageEffect } from '@/modules/socket/room-socket.hook'
import { useCallback } from 'react'
Expand All @@ -14,12 +15,12 @@ export function usePathSocketWatcher() {

const pathCreateHandler = useCallback(
(payload: PathCreatePayload) => {
console.debug('Socket: created path %s', payload.id)
dispatch(PadActions.setPath(payload))
dispatch(PadActions.removeDraftPath(payload.id))
},
[dispatch]
)

useMessageEffect(PadSocketCode.PATH_CREATE, pathCreateHandler)

const pathDraftStartHandler = useCallback(
Expand All @@ -28,7 +29,6 @@ export function usePathSocketWatcher() {
},
[dispatch]
)

useMessageEffect(PadSocketCode.PATH_DRAFT_START, pathDraftStartHandler)

const pathDraftMoveHandler = useCallback(
Expand All @@ -42,6 +42,14 @@ export function usePathSocketWatcher() {
},
[dispatch]
)

useMessageEffect(PadSocketCode.PATH_DRAFT_MOVE, pathDraftMoveHandler)

const pathDeleteHandler = useCallback(
({ id }: PathDeletePayload) => {
console.debug('Socket: deleted path %s', id)
dispatch(PadActions.removePath(id))
},
[dispatch]
)
useMessageEffect(PadSocketCode.PATH_DELETE, pathDeleteHandler)
}
2 changes: 2 additions & 0 deletions client/src/pages/Room/RoomContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ import { If, Then } from 'react-if'
import { useScreen } from '@/modules/common/screen.hook'
import RoomDrawer from '@/modules/room/RoomDrawer'
import BasicButtonTriggeredModal from '@/modules/common/BasicButtonTriggeredModal'
import useUndoCommandListener from '@/modules/pad-common/undo-command-listener.hook'

export default function RoomContent() {
useParticipantWatcher()
usePathSocketWatcher()
useUndoCommandListener()

const pathInputService = usePathInputServiceImpl()
const cursorService = useCursorServiceImpl()
Expand Down
5 changes: 4 additions & 1 deletion client/src/pages/Room/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { useAppDispatch } from '@/store/hooks'
import { PadActions } from '@/modules/pad-common/pad.slice'
import { RoomActions } from '@/modules/room/room.slice'
import ToastProvider from '@/modules/common/ToastProvider'
import { UndoStackProvider } from '@/modules/pad-common/undo-stack.context'

enum RoomErrorType {
NO_USERNAME,
Expand Down Expand Up @@ -132,7 +133,9 @@ export function Component() {
<div data-cy="room-page">
<PadEventsProvider socket={socket} roomId={roomId!}>
<ToastProvider>
<RoomContent />
<UndoStackProvider>
<RoomContent />
</UndoStackProvider>
</ToastProvider>
</PadEventsProvider>
</div>
Expand Down

0 comments on commit 072e189

Please sign in to comment.