diff --git a/apps/client/src/components/Canvas/DrawingCanvas/DrawingCanvas.test.tsx b/apps/client/src/components/Canvas/DrawingCanvas/DrawingCanvas.test.tsx index 1842287b..538c6eb3 100644 --- a/apps/client/src/components/Canvas/DrawingCanvas/DrawingCanvas.test.tsx +++ b/apps/client/src/components/Canvas/DrawingCanvas/DrawingCanvas.test.tsx @@ -1,7 +1,10 @@ +/** + * @vitest-environment happy-dom + */ import Konva from 'konva'; -import { waitFor } from '@testing-library/react'; +import { fireEvent, waitFor } from '@testing-library/react'; import { stateGenerator } from '@/test/data-generators'; -import { renderWithProviders } from '@/test/test-utils'; +import { findCanvas, renderWithProviders } from '@/test/test-utils'; import { createNode } from '@/utils/node'; import DrawingCanvas from './DrawingCanvas'; import { getLayerNodes, getMainLayer } from './helpers/stage'; @@ -30,8 +33,9 @@ describe('DrawingCanvas', () => { const text = createNode('text', [120, 120]); text.text = 'Hello World'; + const nodes = [arrow, rectangle, ellipse, draw, text]; + it('renders shapes', async () => { - const nodes = [arrow, rectangle, ellipse, draw, text]; const preloadedState = stateGenerator({ canvas: { present: { nodes } } }); renderWithProviders( @@ -57,4 +61,50 @@ describe('DrawingCanvas', () => { ); }); }); + + it('inherits style from currentNodeStyle', async () => { + const preloadedState = stateGenerator({ + canvas: { + present: { + toolType: 'rectangle', + currentNodeStyle: { + color: 'blue700', + line: 'dashed', + fill: 'solid', + opacity: 0.5, + animated: true, + }, + }, + }, + }); + + const { store } = renderWithProviders( + vi.fn()} + />, + { preloadedState }, + ); + + const { canvas } = await findCanvas(); + + // start at [10, 20] + fireEvent.pointerDown(canvas, { clientX: 10, clientY: 20 }); + + // move to [30, 40] + fireEvent.pointerMove(canvas, { clientX: 30, clientY: 40 }); + + // stop at last position + fireEvent.pointerUp(canvas); + + await waitFor(() => { + const canvasState = store.getState().canvas.present; + const node = canvasState.nodes[0]; + + expect(node.style).toEqual( + preloadedState.canvas.present.currentNodeStyle, + ); + }); + }); }); diff --git a/apps/client/src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx b/apps/client/src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx index 21307658..e6b656cb 100644 --- a/apps/client/src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx +++ b/apps/client/src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx @@ -12,6 +12,7 @@ import { useAppDispatch, useAppSelector, useAppStore } from '@/stores/hooks'; import { canvasActions, selectConfig, + selectCurrentNodeStyle, selectSelectedNodeIds, selectToolType, } from '@/services/canvas/slice'; @@ -71,6 +72,7 @@ const DrawingCanvas = forwardRef( const stageConfig = useAppSelector(selectConfig); const toolType = useAppSelector(selectToolType); const storedSelectedNodeIds = useAppSelector(selectSelectedNodeIds); + const currentNodeStyle = useAppSelector(selectCurrentNodeStyle); const thisUser = useAppSelector(selectThisUser); const ws = useWebSocket(); @@ -188,7 +190,7 @@ const DrawingCanvas = forwardRef( (toolType: NodeType, position: Point) => { const shouldResetToolType = toolType === 'text'; - const node = createNode(toolType, position); + const node = createNode(toolType, position, currentNodeStyle); setDrafts({ type: 'create', payload: { node } }); setEditingNodeId(node.nodeProps.id); @@ -202,7 +204,7 @@ const DrawingCanvas = forwardRef( ws.send({ type: 'draft-create', data: { node } }); } }, - [ws, dispatch, setDrafts], + [ws, currentNodeStyle, dispatch, setDrafts], ); const handleDraftDraw = useCallback( @@ -332,7 +334,7 @@ const DrawingCanvas = forwardRef( handleDraftCreate(toolType, pointerPosition); } - if (hasSelectedNodes) { + if (hasSelectedNodes && toolType === 'select') { setSelectedNodeIds([]); dispatch(canvasActions.setSelectedNodeIds([])); } diff --git a/apps/client/src/components/Panels/Panels.test.tsx b/apps/client/src/components/Panels/Panels.test.tsx index 7f25b850..44954169 100644 --- a/apps/client/src/components/Panels/Panels.test.tsx +++ b/apps/client/src/components/Panels/Panels.test.tsx @@ -54,7 +54,7 @@ describe('style panel', () => { describe('colors grid', () => { GRID_COLORS.forEach((color) => { - it(`dispatches nodes update with ${color.name} color`, async () => { + it(`dispatches updateNodes and setCurrentNodeStyle with ${color.value} color`, async () => { const { store, user } = renderWithProviders( , { preloadedState }, @@ -69,6 +69,9 @@ describe('style panel', () => { }), ), ); + expect(store.dispatch).toHaveBeenCalledWith( + canvasActions.setCurrentNodeStyle({ color: color.value }), + ); }); }); @@ -91,7 +94,7 @@ describe('style panel', () => { }); describe('opacity', () => { - it('dispatches nodes update with new opacity value', async () => { + it('dispatches updateNodes and setCurrentNodeStyle with new opacity value', async () => { const { user, store } = renderWithProviders( , { @@ -103,22 +106,27 @@ describe('style panel', () => { await user.keyboard('[ArrowLeft]'); + const updatedOpacity = OPACITY.maxValue - OPACITY.step; + expect(store.dispatch).toHaveBeenCalledWith( canvasActions.updateNodes( selectedNodes.map((node) => { return { ...node, - style: { ...node.style, opacity: 1 - OPACITY.step }, + style: { ...node.style, opacity: updatedOpacity }, }; }), ), ); + expect(store.dispatch).toHaveBeenCalledWith( + canvasActions.setCurrentNodeStyle({ opacity: updatedOpacity }), + ); }); }); describe('size', () => { SIZE.forEach((size) => { - it(`dispatches nodes update with ${size.name} size`, async () => { + it(`dispatches updateNodes and setCurrentNodeStyle with ${size.value} size`, async () => { const { store, user } = renderWithProviders( , { preloadedState }, @@ -133,13 +141,16 @@ describe('style panel', () => { }), ), ); + expect(store.dispatch).toHaveBeenCalledWith( + canvasActions.setCurrentNodeStyle({ size: size.value }), + ); }); }); }); describe('fill', () => { FILL.forEach((fill) => { - it(`dispatches nodes update with ${fill.name} fill`, async () => { + it(`dispatches updateNodes and setCurrentNodeStyle with ${fill.value} fill`, async () => { const { store, user } = renderWithProviders( , { preloadedState }, @@ -154,13 +165,16 @@ describe('style panel', () => { }), ), ); + expect(store.dispatch).toHaveBeenCalledWith( + canvasActions.setCurrentNodeStyle({ fill: fill.value }), + ); }); }); }); describe('line', () => { LINE.forEach((line) => { - it(`dispatches nodes update with ${line.name} fill`, async () => { + it(`dispatches updateNodes and setCurrentNodeStyle with ${line.value} fill`, async () => { const { store, user } = renderWithProviders( , { preloadedState }, @@ -175,12 +189,15 @@ describe('style panel', () => { }), ), ); + expect(store.dispatch).toHaveBeenCalledWith( + canvasActions.setCurrentNodeStyle({ line: line.value }), + ); }); }); }); describe('animated', () => { - it(`dispatches nodes update with new animated value`, async () => { + it(`dispatches updateNodes and setCurrentNodeStyle with new animated value`, async () => { const { store, user } = renderWithProviders( , { preloadedState }, @@ -195,6 +212,9 @@ describe('style panel', () => { }), ), ); + expect(store.dispatch).toHaveBeenCalledWith( + canvasActions.setCurrentNodeStyle({ animated: true }), + ); }); }); }); diff --git a/apps/client/src/components/Panels/Panels.tsx b/apps/client/src/components/Panels/Panels.tsx index 2bd37a2e..9c18ab18 100644 --- a/apps/client/src/components/Panels/Panels.tsx +++ b/apps/client/src/components/Panels/Panels.tsx @@ -22,6 +22,7 @@ import LibraryDrawer from '../Library/LibraryDrawer/LibraryDrawer'; import HistoryButtons from './HistoryButtons'; import DeleteButton from './DeleteButton'; import { + LOCAL_STORAGE_COLLAB_KEY, PROJECT_FILE_EXT, PROJECT_FILE_NAME, PROJECT_PNG_EXT, @@ -32,17 +33,17 @@ import { selectLibrary } from '@/services/library/slice'; import { calculateCenterPoint } from '@/utils/position'; import { calculateStageZoomRelativeToPoint } from '../Canvas/DrawingCanvas/helpers/zoom'; import * as Styled from './Panels.styled'; -import Konva from 'konva'; import { shallowEqual } from '@/utils/object'; import { setCursorByToolType } from '../Canvas/DrawingCanvas/helpers/cursor'; import { findStageByName } from '@/utils/node'; +import { storage } from '@/utils/storage'; import type { NodeStyle, User } from 'shared'; import type { HistoryControlKey, MenuPanelActionType, ZoomActionKey, } from '@/constants/panels'; -import type { ToolType } from '@/constants/app'; +import type { StoredCollabState, ToolType } from '@/constants/app'; type Props = { selectedNodeIds: string[]; @@ -80,7 +81,7 @@ const Panels = ({ selectedNodeIds }: Props) => { (type: ToolType) => { dispatch(canvasActions.setToolType(type)); - const stage = Konva.stages[0]; + const stage = findStageByName(DRAWING_CANVAS.NAME); setCursorByToolType(stage, type); }, [dispatch], @@ -98,6 +99,7 @@ const Panels = ({ selectedNodeIds }: Props) => { }); dispatch(canvasActions.updateNodes(updatedNodes)); + dispatch(canvasActions.setCurrentNodeStyle(style)); }; const handleMenuAction = useCallback( @@ -133,7 +135,7 @@ const Panels = ({ selectedNodeIds }: Props) => { const stage = findStageByName(DRAWING_CANVAS.NAME); - setCursorByToolType(stage, project.toolType); + setCursorByToolType(stage, project.toolType ?? 'select'); } else { modal.open({ title: 'Error', @@ -186,6 +188,10 @@ const Panels = ({ selectedNodeIds }: Props) => { const handleUserChange = useCallback( (user: User) => { dispatch(collaborationActions.updateUser(user)); + + storage.set(LOCAL_STORAGE_COLLAB_KEY, { + user: { name: user.name, color: user.color }, + }); }, [dispatch], ); diff --git a/apps/client/src/components/Panels/StylePanel/StylePanel.tsx b/apps/client/src/components/Panels/StylePanel/StylePanel.tsx index bebf757f..d33a1af8 100644 --- a/apps/client/src/components/Panels/StylePanel/StylePanel.tsx +++ b/apps/client/src/components/Panels/StylePanel/StylePanel.tsx @@ -22,6 +22,14 @@ type Props = { onStyleChange: (style: Partial) => void; }; +const getValueIfAllIdentical = < + T extends string | number | boolean | undefined, +>( + set: Set, +): T | undefined => { + return set.size === 1 ? [...set][0] : undefined; +}; + const StylePanel = ({ selectedNodes, onStyleChange }: Props) => { const mergedStyle = useMemo(() => { const styles: NodeStyle[] = selectedNodes.map(({ style }) => style); @@ -33,14 +41,6 @@ const StylePanel = ({ selectedNodes, onStyleChange }: Props) => { const opacities = new Set(styles.map(({ opacity }) => opacity)); const allShapesAnimated = styles.every(({ animated }) => animated); - const getValueIfAllIdentical = < - T extends string | number | boolean | undefined, - >( - set: Set, - ): T | undefined => { - return set.size === 1 ? [...set][0] : undefined; - }; - return { color: getValueIfAllIdentical(colors), line: getValueIfAllIdentical(lines), diff --git a/apps/client/src/components/Panels/UsersPanel/UsersPanel.tsx b/apps/client/src/components/Panels/UsersPanel/UsersPanel.tsx index 07fdf3c5..ff224879 100644 --- a/apps/client/src/components/Panels/UsersPanel/UsersPanel.tsx +++ b/apps/client/src/components/Panels/UsersPanel/UsersPanel.tsx @@ -63,6 +63,8 @@ const EditableUserInfo = ({ }; const handleInputKeyDown = (event: React.KeyboardEvent) => { + event.stopPropagation(); + if (event.key === KEYS.ENTER || event.key === KEYS.ESCAPE) { handleIsEditingToggle(); } diff --git a/apps/client/src/constants/app.ts b/apps/client/src/constants/app.ts index 233b80eb..f3bfe9b9 100644 --- a/apps/client/src/constants/app.ts +++ b/apps/client/src/constants/app.ts @@ -1,5 +1,6 @@ import { Schemas } from 'shared'; import { z } from 'zod'; +import type { User } from 'shared'; export const BASE_URL = 'https://drawflux-api.onrender.com'; export const BASE_URL_DEV = 'http://localhost:7456'; @@ -12,6 +13,7 @@ export const IS_PROD = process.env.NODE_ENV === 'production'; export const LOCAL_STORAGE_KEY = 'drawflux'; export const LOCAL_STORAGE_LIBRARY_KEY = 'drawflux-library'; export const LOCAL_STORAGE_THEME_KEY = 'drawflux-theme'; +export const LOCAL_STORAGE_COLLAB_KEY = 'drawflux-collab'; export const WS_THROTTLE_MS = 16; @@ -45,6 +47,7 @@ export const appState = z.object({ ...CanvasSchema, toolType: z.union([...ShapeTools, z.literal('hand'), z.literal('select')]), selectedNodeIds: z.record(z.string(), z.boolean()), + currentNodeStyle: Schemas.Node.shape.style, }), }); @@ -55,4 +58,5 @@ export const libraryState = z.object({ export type AppState = z.infer; export type LibraryItem = z.infer; export type Library = z.infer; -export type ToolType = AppState['page']['toolType']; \ No newline at end of file +export type ToolType = AppState['page']['toolType']; +export type StoredCollabState = { user: Omit }; diff --git a/apps/client/src/services/canvas/__tests__/slice.test.ts b/apps/client/src/services/canvas/__tests__/slice.test.ts index bdf7b9f4..24945544 100644 --- a/apps/client/src/services/canvas/__tests__/slice.test.ts +++ b/apps/client/src/services/canvas/__tests__/slice.test.ts @@ -3,8 +3,8 @@ import reducer, { canvasActions, initialState, } from '../slice'; -import { nodesGenerator } from '@/test/data-generators'; -import type { AppState, ToolType } from '@/constants/app'; +import { nodesGenerator, stateGenerator } from '@/test/data-generators'; +import type { ToolType } from '@/constants/app'; import type { NodeObject, StageConfig } from 'shared'; describe('canvas slice', () => { @@ -13,16 +13,15 @@ describe('canvas slice', () => { }); it('sets the state', () => { - const stateToSet: AppState['page'] = { - nodes: nodesGenerator(5), - stageConfig: { position: { x: 50, y: 50 }, scale: 0.5 }, - selectedNodeIds: {}, - toolType: 'select', - }; + const stateToSet = stateGenerator({ + canvas: { + present: { nodes: nodesGenerator(5, 'ellipse') }, + }, + }).canvas.present; const state = reducer(undefined, canvasActions.set(stateToSet)); - expect(state).toEqual({ ...initialState, ...stateToSet }); + expect(state).toEqual(stateToSet); }); /** @@ -37,6 +36,49 @@ describe('canvas slice', () => { expect(state).toEqual({ ...initialState, nodes }); }); + it('adds nodes and selects them if selectNodes is true', () => { + const nodes = nodesGenerator(5, 'ellipse'); + + const state = reducer( + undefined, + canvasActions.addNodes(nodes, { selectNodes: true }), + ); + + const selectedNodeIds = Object.fromEntries( + nodes.map((node) => [node.nodeProps.id, true]), + ); + + expect(state).toEqual({ ...initialState, nodes, selectedNodeIds }); + }); + + it('adds nodes and duplicates them if duplicate is true', () => { + const nodes = nodesGenerator(5, 'ellipse'); + + const previousState: CanvasSliceState = { ...initialState, nodes }; + + const state = reducer( + previousState, + canvasActions.addNodes(nodes, { duplicate: true }), + ); + + expect(state.nodes).toHaveLength(10); + expect(state.nodes.slice(0, 5)).toEqual(nodes); + expect(state.nodes.slice(5)).toEqual( + expect.arrayContaining( + nodes.map((node) => { + return expect.objectContaining({ + ...node, + nodeProps: { + ...node.nodeProps, + id: expect.any(String), + point: [expect.any(Number), expect.any(Number)], + }, + }); + }), + ), + ); + }); + it('updates nodes', () => { const nodes = nodesGenerator(5, 'ellipse'); @@ -183,4 +225,21 @@ describe('canvas slice', () => { ), }); }); + + it('unselects all nodes', () => { + const nodes = nodesGenerator(5, 'ellipse'); + + const previousState: CanvasSliceState = { + ...initialState, + nodes, + selectedNodeIds: { + [nodes[0].nodeProps.id]: true, + [nodes[3].nodeProps.id]: true, + }, + }; + + const state = reducer(previousState, canvasActions.unselectAllNodes()); + + expect(state).toEqual({ ...previousState, selectedNodeIds: {} }); + }); }); diff --git a/apps/client/src/services/canvas/slice.ts b/apps/client/src/services/canvas/slice.ts index f4f7b9af..7c751168 100644 --- a/apps/client/src/services/canvas/slice.ts +++ b/apps/client/src/services/canvas/slice.ts @@ -6,7 +6,7 @@ import { createParametricSelectorHook, createAppSelector, } from '@/stores/hooks'; -import type { NodeObject } from 'shared'; +import type { NodeObject, NodeStyle } from 'shared'; import type { AppState } from '@/constants/app'; import type { RootState } from '../../stores/store'; import type { PayloadAction } from '@reduxjs/toolkit'; @@ -27,6 +27,13 @@ export const initialState: CanvasSliceState = { toolType: 'select', selectedNodeIds: {}, copiedNodes: [], + currentNodeStyle: { + color: 'black', + size: 'medium', + animated: false, + line: 'solid', + opacity: 1, + }, }; export type ActionMeta = { @@ -47,13 +54,8 @@ export const canvasSlice = createSlice({ name: 'canvas', initialState, reducers: { - set: (state, action: PayloadAction) => { - const { nodes, selectedNodeIds, stageConfig, toolType } = action.payload; - - state.nodes = nodes; - state.selectedNodeIds = selectedNodeIds; - state.stageConfig = stageConfig; - state.toolType = toolType; + set: (state, action: PayloadAction>) => { + return { ...state, ...action.payload }; }, setNodes: { reducer: (state, action: PayloadAction) => { @@ -167,6 +169,9 @@ export const canvasSlice = createSlice({ unselectAllNodes: (state) => { state.selectedNodeIds = {}; }, + setCurrentNodeStyle: (state, action: PayloadAction>) => { + state.currentNodeStyle = { ...state.currentNodeStyle, ...action.payload }; + }, }, extraReducers(builder) { builder @@ -220,6 +225,9 @@ const selectNodesById = createAppSelector( export const useSelectNodesById = createParametricSelectorHook(selectNodesById); +export const selectCurrentNodeStyle = (state: RootState) => + state.canvas.present.currentNodeStyle; + export const selectCopiedNodes = (state: RootState) => state.canvas.present.copiedNodes; diff --git a/apps/client/src/services/collaboration/listeners.ts b/apps/client/src/services/collaboration/listeners.ts index d5126e79..c5f0d04e 100644 --- a/apps/client/src/services/collaboration/listeners.ts +++ b/apps/client/src/services/collaboration/listeners.ts @@ -2,10 +2,13 @@ import { addAppListener } from '@/stores/middlewares/listenerMiddleware'; import { isAnyOf } from '@reduxjs/toolkit'; import { collaborationActions } from './slice'; import { canvasActions } from '../canvas/slice'; +import { storage } from '@/utils/storage'; import api from '@/services/api'; +import { LOCAL_STORAGE_COLLAB_KEY } from '@/constants/app'; import type { WebSocketContextValue } from '@/contexts/websocket'; import type { AppDispatch } from '@/stores/store'; import type { ActionMeta } from '../canvas/slice'; +import type { StoredCollabState } from '@/constants/app'; /** * subscribe to canvas/collaboration actions and send corresponding messages @@ -83,7 +86,19 @@ export const subscribeToIncomingCollabMessages = ( const subscribers = [ ws.subscribe('room-joined', (data) => { - dispatch(collaborationActions.init(data)); + const collabState = storage.get( + LOCAL_STORAGE_COLLAB_KEY, + ); + + if (collabState) { + const thisUser = { ...data.thisUser, ...collabState.user }; + + ws.send({ type: 'user-change', data: thisUser }); + + dispatch(collaborationActions.init({ ...data, thisUser })); + } else { + dispatch(collaborationActions.init(data)); + } }), ws.subscribe('user-joined', (data) => dispatch(collaborationActions.addUser(data)), diff --git a/apps/client/src/test/data-generators.ts b/apps/client/src/test/data-generators.ts index 5323a5ba..78325593 100644 --- a/apps/client/src/test/data-generators.ts +++ b/apps/client/src/test/data-generators.ts @@ -62,3 +62,7 @@ export const libraryGenerator = (length: number, shapes = 1): Library => { export const makeCollabRoomURL = (roomId: string, baseUrl = window.origin) => { return urlSearchParam.set(CONSTANTS.COLLAB_ROOM_URL_PARAM, roomId, baseUrl); }; + +export const nodeTypeGenerator = (): NodeType[] => { + return ['arrow', 'draw', 'ellipse', 'laser', 'rectangle', 'text']; +}; diff --git a/apps/client/src/utils/file.ts b/apps/client/src/utils/file.ts index 7b60ff24..74232318 100644 --- a/apps/client/src/utils/file.ts +++ b/apps/client/src/utils/file.ts @@ -1,4 +1,5 @@ -import { PROJECT_FILE_EXT, appState } from '@/constants/app'; +import { type AppState, PROJECT_FILE_EXT, appState } from '@/constants/app'; +import { safeJSONParse } from './object'; export function downloadDataUrlAsFile( dataUrl: string, @@ -48,9 +49,9 @@ export async function importProject() { if (!fileContents || !isJsonString(fileContents)) return null; - const data = JSON.parse(fileContents); + const data = safeJSONParse>(fileContents); - return (await appState.shape.page.parseAsync(data)) ?? null; + return await appState.shape.page.partial().parseAsync(data); } catch (error) { return null; } diff --git a/apps/client/src/utils/node.ts b/apps/client/src/utils/node.ts index a29c1887..04674213 100644 --- a/apps/client/src/utils/node.ts +++ b/apps/client/src/utils/node.ts @@ -7,19 +7,26 @@ import { import { DUPLICATION_GAP } from '@/constants/canvas'; import Konva from 'konva'; import type { IRect, Vector2d } from 'konva/lib/types'; -import type { NodeType, Point, NodeObject, StageConfig } from 'shared'; +import type { + NodeType, + Point, + NodeObject, + StageConfig, + NodeStyle, +} from 'shared'; export const createNode = ( type: T, point: Point, + style?: NodeStyle, ): NodeObject => { return { type, text: null, - style: { + style: style ?? { opacity: 1, line: 'solid', - color: type === 'laser' ? 'red600' : 'black', + color: 'black', size: 'medium', animated: false, }, diff --git a/apps/server/src/features/Collaboration/__tests__/helpers.test.ts b/apps/server/src/features/Collaboration/__tests__/helpers.test.ts deleted file mode 100644 index 38c181f4..00000000 --- a/apps/server/src/features/Collaboration/__tests__/helpers.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { WebSocket } from 'ws'; -import { COLORS, DEFAULT_COLOR } from '../constants'; -import { getUnusedUserColor } from '../helpers'; -import { CollabUser } from '../models'; - -const ws = vi.fn( - () => - ({ - send: vi.fn(), - readyState: WebSocket.OPEN, - } as unknown as WebSocket), -); - -describe('getUnusedUserColor', () => { - it('should return an unused color', () => { - const usedColors = [COLORS[0], COLORS[1]]; - - const users = usedColors.map((color) => { - return new CollabUser('User', color, new ws()); - }); - - expect(getUnusedUserColor(users)).not.toContain(usedColors); - }); - - it('should return default color if all colors are used or room has no users', () => { - const usedColors = COLORS; - - const users = usedColors.map((color) => { - return new CollabUser('User', color, new ws()); - }); - - expect(getUnusedUserColor(users)).toBe(DEFAULT_COLOR); - expect(getUnusedUserColor([])).toBe(DEFAULT_COLOR); - }); -}); diff --git a/apps/server/src/features/Collaboration/__tests__/models.test.ts b/apps/server/src/features/Collaboration/__tests__/models.test.ts index 1799ed94..5829832e 100644 --- a/apps/server/src/features/Collaboration/__tests__/models.test.ts +++ b/apps/server/src/features/Collaboration/__tests__/models.test.ts @@ -1,6 +1,12 @@ import { WSMessageUtil, type User, type WSMessage } from 'shared'; import WebSocket from 'ws'; -import { MAX_USERS } from '../constants'; +import { + COLORS, + DEFAULT_COLOR, + DEFAULT_NAME, + MAX_USERS, + USER_NAMES, +} from '../constants'; import { CollabRoom, CollabUser } from '../models'; vi.mock('ws'); @@ -26,9 +32,9 @@ describe('CollabRoom', () => { }); it('should broadcast to all users except the broadcaster', () => { - const user1 = new CollabUser('User-1', 'black', new ws()); - const user2 = new CollabUser('User-2', 'blue600', new ws()); - const user3 = new CollabUser('User-3', 'green600', new ws()); + const user1 = new CollabUser(new ws()); + const user2 = new CollabUser(new ws()); + const user3 = new CollabUser(new ws()); room.addUser(user1); room.addUser(user2); @@ -47,15 +53,19 @@ describe('CollabRoom', () => { expect(user3.getWS().send).toHaveBeenCalledOnce(); }); - it('should add user to the room', () => { - const user = new CollabUser('User', 'black', new ws()); + it('initializes and adds a new user', () => { + const user = new CollabUser(new ws()); room.addUser(user); + + expect(room.users).toHaveLength(1); expect(room.users).toContain(user); + expect(COLORS.includes(room.users[0].color)).toBe(true); + expect(USER_NAMES.includes(room.users[0].name as never)).toBe(true); }); it('should remove user from the room', () => { - const user = new CollabUser('User', 'black', new ws()); + const user = new CollabUser(new ws()); room.addUser(user); room.removeUser(user.id); @@ -64,7 +74,7 @@ describe('CollabRoom', () => { }); it('should update user', () => { - const user = new CollabUser('User', 'black', new ws()); + const user = new CollabUser(new ws()); room.addUser(user); const updatedUser: User = { id: user.id, name: 'User-2', color: 'blue600' }; @@ -77,7 +87,7 @@ describe('CollabRoom', () => { expect(room.hasReachedMaxUsers()).toBe(false); for (let i = 0; i < MAX_USERS; i++) { - const user = new CollabUser('User', 'black', new ws()); + const user = new CollabUser(new ws()); room.addUser(user); } @@ -85,8 +95,8 @@ describe('CollabRoom', () => { }); it('should check if room has multiple users', () => { - const user1 = new CollabUser('User-1', 'black', new ws()); - const user2 = new CollabUser('User-2', 'blue600', new ws()); + const user1 = new CollabUser(new ws()); + const user2 = new CollabUser(new ws()); room.addUser(user1); expect(room.hasMultipleUsers()).toBe(false); @@ -96,7 +106,7 @@ describe('CollabRoom', () => { }); it('should check if room is empty', () => { - const user = new CollabUser('User', 'black', new ws()); + const user = new CollabUser(new ws()); room.addUser(user); expect(room.isEmpty()).toBe(false); @@ -108,15 +118,15 @@ describe('CollabRoom', () => { describe('CollabUser', () => { it('should init a user correctly', () => { - const user = new CollabUser('User', 'black', new ws()); + const user = new CollabUser(new ws()); expect(user.id).toHaveLength(36); - expect(user.name).toBe('User'); - expect(user.color).toBe('black'); + expect(user.name).toBe(DEFAULT_NAME); + expect(user.color).toBe(DEFAULT_COLOR); }); it('should update user', () => { - const user = new CollabUser('User', 'black', new ws()); + const user = new CollabUser(new ws()); user.update({ color: 'blue700', name: 'User-2' }); @@ -127,7 +137,7 @@ describe('CollabUser', () => { it('should get ws instance', () => { const wsInstance = new ws(); - const user = new CollabUser('User', 'black', wsInstance); + const user = new CollabUser(wsInstance); expect(user.getWS()).toBe(wsInstance); }); diff --git a/apps/server/src/features/Collaboration/constants.ts b/apps/server/src/features/Collaboration/constants.ts index 9cfcbafa..7e62ac0e 100644 --- a/apps/server/src/features/Collaboration/constants.ts +++ b/apps/server/src/features/Collaboration/constants.ts @@ -9,4 +9,12 @@ export const COLORS = [ 'gray600', ] as User['color'][]; -export const DEFAULT_COLOR: User['color'] = 'gray600'; +export const USER_NAMES = [ + 'Blue Cactus', + 'Golden Mango', + 'Smooth Avocado', + 'Ultimate Potato', +] as const; + +export const DEFAULT_COLOR = COLORS[3]; +export const DEFAULT_NAME = 'New User'; diff --git a/apps/server/src/features/Collaboration/controller.ts b/apps/server/src/features/Collaboration/controller.ts index 3a73d2e6..d31138ee 100644 --- a/apps/server/src/features/Collaboration/controller.ts +++ b/apps/server/src/features/Collaboration/controller.ts @@ -1,9 +1,10 @@ +import { CollabRoom, CollabUser } from './models'; +import { findPage } from './helpers'; +import { WSMessageUtil } from 'shared'; import type { IncomingMessage } from 'http'; -import { type WSMessage, WSMessageUtil } from 'shared'; +import type { WSMessage } from 'shared'; import type { RawData } from 'ws'; -import { type PatchedWebSocket } from '@/services/websocket'; -import { createUsername, findPage, getUnusedUserColor } from './helpers'; -import { CollabRoom, CollabUser } from './models'; +import type { PatchedWebSocket } from '@/services/websocket'; const rooms = new Map>(); @@ -28,10 +29,7 @@ export async function initCollabConnection( ); } - const userColor = getUnusedUserColor(room.users); - const username = createUsername(room); - - const user = new CollabUser(username, userColor, ws); + const user = new CollabUser(ws); room.addUser(user); rooms.set(room.id, room); diff --git a/apps/server/src/features/Collaboration/helpers.ts b/apps/server/src/features/Collaboration/helpers.ts index 02e146a2..3925b5fc 100644 --- a/apps/server/src/features/Collaboration/helpers.ts +++ b/apps/server/src/features/Collaboration/helpers.ts @@ -1,7 +1,4 @@ -import type { User } from 'shared'; import * as queries from '@/features/Page/queries/index'; -import { COLORS, DEFAULT_COLOR } from './constants'; -import type { CollabRoom, CollabUser } from './models'; export async function findPage(id: string) { try { @@ -9,19 +6,4 @@ export async function findPage(id: string) { } catch (error) { return null; } -} - -export function getUnusedUserColor(users: CollabUser[]): User['color'] { - if (users.length === 0) { - return DEFAULT_COLOR; - } - - const usedColors = new Set(users.map((user) => user.color)); - return COLORS.find((color) => !usedColors.has(color)) || DEFAULT_COLOR; -} - -export function createUsername(room: CollabRoom) { - const suffix = room.hasMultipleUsers() ? ` ${room.users.length}` : ''; - - return `New User${suffix}`; -} +} \ No newline at end of file diff --git a/apps/server/src/features/Collaboration/models.ts b/apps/server/src/features/Collaboration/models.ts index 8dade535..73e56edd 100644 --- a/apps/server/src/features/Collaboration/models.ts +++ b/apps/server/src/features/Collaboration/models.ts @@ -1,7 +1,13 @@ import { randomUUID } from 'crypto'; -import type { Room, User } from 'shared'; import WebSocket from 'ws'; -import { MAX_USERS } from './constants'; +import { + COLORS, + DEFAULT_COLOR, + DEFAULT_NAME, + MAX_USERS, + USER_NAMES, +} from './constants'; +import type { Room, User } from 'shared'; export class CollabRoom implements Room { id; @@ -12,6 +18,11 @@ export class CollabRoom implements Room { } addUser(user: InstanceType) { + const color = this.#getUnusedUserColor(); + const name = this.#getUnusedUsername(); + + user.update({ color, name }); + this.users.push(user); } @@ -55,18 +66,25 @@ export class CollabRoom implements Room { isEmpty() { return this.users.length === 0; } + + #getUnusedUserColor() { + const usedColors = new Set(this.users.map(({ color }) => color)); + return COLORS.find((color) => !usedColors.has(color)) ?? DEFAULT_COLOR; + } + + #getUnusedUsername() { + const usedUserNames = new Set(this.users.map(({ name }) => name)); + return USER_NAMES.find((name) => !usedUserNames.has(name)) ?? DEFAULT_NAME; + } } export class CollabUser implements User { - id; - name; - color; - #ws: WebSocket; - - constructor(name: string, color: User['color'], ws: WebSocket) { - this.id = randomUUID(); - this.name = name; - this.color = color; + id = randomUUID(); + name = DEFAULT_NAME; + color = DEFAULT_COLOR; + #ws; + + constructor(ws: WebSocket) { this.#ws = ws; }