From 0cb03b088d1b4c481653f0a806d9d46648c51b60 Mon Sep 17 00:00:00 2001 From: kwaksj329 Date: Tue, 3 Dec 2024 13:25:57 +0900 Subject: [PATCH 1/7] feat: Add timestamp property to CRDT drawing and history types --- client/src/types/canvas.types.ts | 1 + core/types/crdt.types.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/client/src/types/canvas.types.ts b/client/src/types/canvas.types.ts index 8b3ff30d9..3f5282d25 100644 --- a/client/src/types/canvas.types.ts +++ b/client/src/types/canvas.types.ts @@ -33,6 +33,7 @@ export interface StrokeHistoryEntry { strokeIds: string[]; isLocal: boolean; drawingData: DrawingData; + timestamp: number; } export interface DrawingOptions { diff --git a/core/types/crdt.types.ts b/core/types/crdt.types.ts index 3105ab0bd..da38583b0 100644 --- a/core/types/crdt.types.ts +++ b/core/types/crdt.types.ts @@ -11,6 +11,7 @@ export interface StrokeStyle { export interface DrawingData { points: Point[]; style: StrokeStyle; + timestamp: number; } export type RegisterState = [peerId: string, timestamp: number, value: T]; From e1059a7d6179a7506e8f3a5255f6c3abfce785c4 Mon Sep 17 00:00:00 2001 From: kwaksj329 Date: Tue, 3 Dec 2024 13:26:32 +0900 Subject: [PATCH 2/7] feat: Add merge logic to LWWMap considering timestamps --- core/crdt/LWWMap.ts | 122 +++++++++++++++++++++++++++++++------------- 1 file changed, 86 insertions(+), 36 deletions(-) diff --git a/core/crdt/LWWMap.ts b/core/crdt/LWWMap.ts index 5f873227e..e092cdb88 100644 --- a/core/crdt/LWWMap.ts +++ b/core/crdt/LWWMap.ts @@ -1,16 +1,30 @@ -import { MapState, RegisterState, DrawingData } from '@/types/crdt.types'; +import { DrawingData, MapState, RegisterState } from '@/types/crdt.types'; import { LWWRegister } from './LWWRegister'; export class LWWMap { readonly id: string; #data: Map>; + #sortedStrokes: Array<{ id: string; stroke: DrawingData }>; constructor(id: string, initialState: MapState = {}) { this.id = id; this.#data = new Map(); + this.#sortedStrokes = []; - for (const [key, registerState] of Object.entries(initialState)) { - this.#data.set(key, new LWWRegister(this.id, registerState)); + const entries = Object.entries(initialState) + .map(([key, state]) => ({ + key, + register: new LWWRegister(this.id, state), + timestamp: state[1], + })) + .sort((a, b) => b.timestamp - a.timestamp); + + for (const entry of entries) { + this.#data.set(entry.key, entry.register); + const value = entry.register.value; + if (value !== null) { + this.#sortedStrokes.push({ id: entry.key, stroke: value }); + } } } @@ -22,69 +36,105 @@ export class LWWMap { return state; } - get strokes(): { id: string; stroke: DrawingData }[] { - const result = []; - for (const [key, register] of this.#data.entries()) { - const value = register.value; - if (value !== null) { - result.push({ id: key, stroke: value }); + get strokes(): Array<{ id: string; stroke: DrawingData }> { + return this.#sortedStrokes; + } + + // 정렬된 배열에 새로운 스트로크를 삽입할 인덱스 찾기 + private findSortedInsertIndex(newStroke: DrawingData): number { + for (let i = this.#sortedStrokes.length - 1; i >= 0; i--) { + const item = this.#sortedStrokes[i]; + if (item.stroke.timestamp <= newStroke.timestamp) { + return i + 1; } } - return result; + return 0; + } + + // 정렬된 배열에 새로운 스트로크 삽입 + private insertSortedStroke(id: string, stroke: DrawingData): 'end' | 'middle' { + const index = this.findSortedInsertIndex(stroke); + + if (index === this.#sortedStrokes.length) { + this.#sortedStrokes.push({ id, stroke }); + return 'end'; + } + + this.#sortedStrokes.splice(index, 0, { id, stroke }); + return 'middle'; } - // 선 생성 - addStroke(stroke: DrawingData): string { - const timestamp = Date.now(); - const id = `${this.id}-${timestamp}-${Math.random().toString(36).substring(2, 9)}`; - const register = new LWWRegister(this.id, [this.id, timestamp, stroke]); + // 새로운 스트로크 추가 + addStroke(stroke: DrawingData): { id: string; position: 'end' | 'middle' } { + const id = `${this.id}-${stroke.timestamp}-${Math.random().toString(36).substring(2, 9)}`; + const register = new LWWRegister(this.id, [this.id, stroke.timestamp, stroke]); + this.#data.set(id, register); - return id; + const position = this.insertSortedStroke(id, stroke); + + return { id, position }; } - // 선 삭제 + // 스트로크 삭제 deleteStroke(id: string): boolean { const register = this.#data.get(id); if (register) { register.set(null); + const index = this.#sortedStrokes.findIndex((item) => item.id === id); + if (index !== -1) { + this.#sortedStrokes.splice(index, 1); + } return true; } return false; } - // 원격 상태 병합 - merge(remoteState: MapState): string[] { + // 전체 상태 병합 + merge(remoteState: MapState): { updatedKeys: string[]; requiresRedraw: boolean } { const updatedKeys: string[] = []; + let requiresRedraw = false; for (const [key, remoteRegisterState] of Object.entries(remoteState)) { - const localRegister = this.#data.get(key); - - if (localRegister) { - // 기존 레지스터가 있으면 병합 - if (localRegister.merge(remoteRegisterState)) { - updatedKeys.push(key); - } - } else { - // 새로운 레지스터면 추가 - this.#data.set(key, new LWWRegister(this.id, remoteRegisterState)); + const result = this.mergeRegister(key, remoteRegisterState); + if (result.updated) { updatedKeys.push(key); + if (result.position === 'middle') { + requiresRedraw = true; + } } } - return updatedKeys; + return { updatedKeys, requiresRedraw }; } - // 단일 레지스터 업데이트 - mergeRegister(key: string, remoteRegisterState: RegisterState): boolean { + // 단일 레지스터 병합 + mergeRegister( + key: string, + remoteRegisterState: RegisterState, + ): { updated: boolean; position: 'end' | 'middle' } { const localRegister = this.#data.get(key); + const [, , remoteValue] = remoteRegisterState; + let position: 'end' | 'middle' = 'end'; if (localRegister) { - // 기존 stroke에 대한 원격 업데이트 병합 - return localRegister.merge(remoteRegisterState); + const wasUpdated = localRegister.merge(remoteRegisterState); + if (wasUpdated) { + const index = this.#sortedStrokes.findIndex((item) => item.id === key); + if (index !== -1) { + this.#sortedStrokes.splice(index, 1); + } + if (remoteValue !== null) { + position = this.insertSortedStroke(key, remoteValue); + } + return { updated: true, position }; + } + return { updated: false, position }; } else { - // 새로운 stroke 추가 this.#data.set(key, new LWWRegister(this.id, remoteRegisterState)); - return true; + if (remoteValue !== null) { + position = this.insertSortedStroke(key, remoteValue); + } + return { updated: true, position }; } } } From 075d8fb41ede0074e33c35c8387de5416d63a002 Mon Sep 17 00:00:00 2001 From: kwaksj329 Date: Tue, 3 Dec 2024 13:27:30 +0900 Subject: [PATCH 3/7] feat: Update floodFill to include timestamp property in CRDT drawing type --- client/src/hooks/canvas/useDrawingOperation.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/hooks/canvas/useDrawingOperation.ts b/client/src/hooks/canvas/useDrawingOperation.ts index c2c660cca..4323fe81e 100644 --- a/client/src/hooks/canvas/useDrawingOperation.ts +++ b/client/src/hooks/canvas/useDrawingOperation.ts @@ -174,6 +174,7 @@ export const useDrawingOperation = ( return { points: filledPoints, style: getCurrentStyle(), + timestamp: Date.now(), }; }, [currentColor, inkRemaining, getCurrentStyle, setInkRemaining], From c25fb71277be642b764e8a4a5d2159fcee7ec7ff Mon Sep 17 00:00:00 2001 From: kwaksj329 Date: Tue, 3 Dec 2024 13:28:17 +0900 Subject: [PATCH 4/7] refactor: Update useDrawing hook methods to handle timestamp-based merging --- client/src/hooks/canvas/useDrawing.ts | 94 ++++++++++++++++----------- 1 file changed, 55 insertions(+), 39 deletions(-) diff --git a/client/src/hooks/canvas/useDrawing.ts b/client/src/hooks/canvas/useDrawing.ts index 6408ff16f..e91a8cd99 100644 --- a/client/src/hooks/canvas/useDrawing.ts +++ b/client/src/hooks/canvas/useDrawing.ts @@ -6,6 +6,7 @@ import { CRDTUpdateMessage, CRDTSyncMessage, RoomStatus, + DrawingData, } from '@troublepainter/core'; import { useDrawingOperation } from './useDrawingOperation'; import { useDrawingState } from './useDrawingState'; @@ -87,6 +88,26 @@ export const useDrawing = ( const operation = useDrawingOperation(canvasRef, state); const currentDrawingPoints = useRef([]); + const createDrawingData = useCallback( + (points: Point[]): DrawingData => ({ + points, + style: operation.getCurrentStyle(), + timestamp: Date.now(), + }), + [operation], + ); + + const renderStroke = useCallback( + (strokeData: DrawingData, position: 'middle' | 'end') => { + if (position === 'middle' || strokeData.points.length > 2) { + operation.redrawCanvas(); + } else { + operation.drawStroke(strokeData); + } + }, + [operation], + ); + const startDrawing = useCallback( (point: Point): CRDTUpdateMessage | null => { if (state.checkInkAvailability() === false || !state.crdtRef.current) return null; @@ -97,31 +118,28 @@ export const useDrawing = ( const drawingData = state.drawingMode === DRAWING_MODE.FILL ? operation.floodFill(Math.floor(point.x), Math.floor(point.y)) - : { - points: [point], - style: operation.getCurrentStyle(), - }; + : createDrawingData([point]); if (!drawingData) return null; - const strokeId = state.crdtRef.current.addStroke(drawingData); - state.currentStrokeIdsRef.current.push(strokeId); - if (state.drawingMode === DRAWING_MODE.PEN) operation.drawStroke(drawingData); + const { id, position } = state.crdtRef.current.addStroke(drawingData); + state.currentStrokeIdsRef.current.push(id); + renderStroke(drawingData, position); return { type: CRDTMessageTypes.UPDATE, state: { - key: strokeId, - register: state.crdtRef.current.state[strokeId], + key: id, + register: state.crdtRef.current.state[id], }, }; }, - [state, operation], + [state, operation, createDrawingData, renderStroke], ); const continueDrawing = useCallback( (point: Point): CRDTUpdateMessage | null => { - if (!state.crdtRef.current || currentDrawingPoints.current.length === 0 || state.inkRemaining <= 0) return null; + if (!state.crdtRef.current || currentDrawingPoints.current.length === 0) return null; if (state.drawingMode === DRAWING_MODE.FILL) return null; const lastPoint = currentDrawingPoints.current[currentDrawingPoints.current.length - 1]; @@ -133,27 +151,23 @@ export const useDrawing = ( state.setInkRemaining((prev: number) => Math.max(0, prev - pixelsUsed)); currentDrawingPoints.current.push(point); + const drawingData = createDrawingData([...currentDrawingPoints.current]); - const drawingData = { - points: [...currentDrawingPoints.current], - style: operation.getCurrentStyle(), - }; - - const strokeId = state.crdtRef.current.addStroke(drawingData); - state.currentStrokeIdsRef.current.push(strokeId); - operation.drawStroke(drawingData); + const { id, position } = state.crdtRef.current.addStroke(drawingData); + state.currentStrokeIdsRef.current.push(id); + renderStroke(drawingData, position); currentDrawingPoints.current = [point]; return { type: CRDTMessageTypes.UPDATE, state: { - key: strokeId, - register: state.crdtRef.current.state[strokeId], + key: id, + register: state.crdtRef.current.state[id], }, }; }, - [state, operation], + [state, createDrawingData, renderStroke], ); const stopDrawing = useCallback(() => { @@ -172,23 +186,20 @@ export const useDrawing = ( } }); - const drawingData = { - points: allPoints, - style: operation.getCurrentStyle(), - }; + const drawingData = createDrawingData(allPoints); state.strokeHistoryRef.current.push({ strokeIds: [...state.currentStrokeIdsRef.current], isLocal: true, drawingData, + timestamp: drawingData.timestamp, }); state.historyPointerRef.current = state.strokeHistoryRef.current.length - 1; - currentDrawingPoints.current = []; state.currentStrokeIdsRef.current = []; state.updateHistoryState(); - }, [state]); + }, [state, createDrawingData]); const undo = useCallback((): CRDTUpdateMessage[] | null => { if (!state.crdtRef.current || state.historyPointerRef.current < 0) return null; @@ -235,18 +246,17 @@ export const useDrawing = ( if (!nextEntry?.isLocal || !nextEntry.drawingData) return null; - const strokeId = state.crdtRef.current.addStroke(nextEntry.drawingData); + const { id } = state.crdtRef.current.addStroke(nextEntry.drawingData); + nextEntry.strokeIds = [id]; const update: CRDTUpdateMessage = { type: CRDTMessageTypes.UPDATE, state: { - key: strokeId, - register: state.crdtRef.current.state[strokeId], + key: id, + register: state.crdtRef.current.state[id], }, }; - nextEntry.strokeIds = [strokeId]; - state.historyPointerRef.current++; state.updateHistoryState(); operation.redrawCanvas(); @@ -259,8 +269,10 @@ export const useDrawing = ( if (!state.crdtRef.current) return; if (crdtDrawingData.type === CRDTMessageTypes.SYNC) { - state.crdtRef.current.merge(crdtDrawingData.state); - operation.redrawCanvas(); + const { requiresRedraw } = state.crdtRef.current.merge(crdtDrawingData.state); + if (requiresRedraw) { + operation.redrawCanvas(); + } if (roomStatus === 'DRAWING') { state.strokeHistoryRef.current = []; @@ -272,7 +284,10 @@ export const useDrawing = ( const peerId = key.split('-')[0]; const isLocalUpdate = peerId === state.currentPlayerId; - if (!state.crdtRef.current.mergeRegister(key, register) || isLocalUpdate) return; + if (isLocalUpdate) return; + + const { updated, position } = state.crdtRef.current.mergeRegister(key, register); + if (!updated) return; const stroke = register[2]; if (!stroke) { @@ -280,8 +295,7 @@ export const useDrawing = ( return; } - if (stroke.points.length > 2) operation.applyFill(stroke); - else operation.drawStroke(stroke); + renderStroke(stroke, position); if (state.historyPointerRef.current < state.strokeHistoryRef.current.length - 1) { state.strokeHistoryRef.current = state.strokeHistoryRef.current.slice(0, state.historyPointerRef.current + 1); @@ -291,12 +305,14 @@ export const useDrawing = ( strokeIds: [key], isLocal: false, drawingData: stroke, + timestamp: stroke.timestamp, }); + state.historyPointerRef.current++; state.updateHistoryState(); } }, - [state.currentPlayerId, operation, roomStatus], + [state.currentPlayerId, operation, roomStatus, renderStroke], // renderStroke 의존성 추가 ); const getAllDrawingData = useCallback((): CRDTSyncMessage | null => { From 49a62c6c9c3c6cab7985b7b9458ba3cb6ab89e49 Mon Sep 17 00:00:00 2001 From: kwaksj329 Date: Tue, 3 Dec 2024 13:39:23 +0900 Subject: [PATCH 5/7] fix: Update conditional logic in renderStroke --- client/src/hooks/canvas/useDrawing.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/src/hooks/canvas/useDrawing.ts b/client/src/hooks/canvas/useDrawing.ts index e91a8cd99..912fb6ab5 100644 --- a/client/src/hooks/canvas/useDrawing.ts +++ b/client/src/hooks/canvas/useDrawing.ts @@ -99,10 +99,14 @@ export const useDrawing = ( const renderStroke = useCallback( (strokeData: DrawingData, position: 'middle' | 'end') => { - if (position === 'middle' || strokeData.points.length > 2) { + if (position === 'middle') { operation.redrawCanvas(); } else { - operation.drawStroke(strokeData); + if (strokeData.points.length > 2) { + operation.applyFill(strokeData); + } else { + operation.drawStroke(strokeData); + } } }, [operation], From 2224562025c29104945cb549a7ed3fe58765107c Mon Sep 17 00:00:00 2001 From: kwaksj329 Date: Tue, 3 Dec 2024 13:46:10 +0900 Subject: [PATCH 6/7] chore: Remove unnecessary comments --- client/src/hooks/canvas/useDrawing.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/hooks/canvas/useDrawing.ts b/client/src/hooks/canvas/useDrawing.ts index 912fb6ab5..d17160e25 100644 --- a/client/src/hooks/canvas/useDrawing.ts +++ b/client/src/hooks/canvas/useDrawing.ts @@ -143,7 +143,7 @@ export const useDrawing = ( const continueDrawing = useCallback( (point: Point): CRDTUpdateMessage | null => { - if (!state.crdtRef.current || currentDrawingPoints.current.length === 0) return null; + if (!state.crdtRef.current || currentDrawingPoints.current.length === 0 || state.inkRemaining <= 0) return null; if (state.drawingMode === DRAWING_MODE.FILL) return null; const lastPoint = currentDrawingPoints.current[currentDrawingPoints.current.length - 1]; @@ -316,7 +316,7 @@ export const useDrawing = ( state.updateHistoryState(); } }, - [state.currentPlayerId, operation, roomStatus, renderStroke], // renderStroke 의존성 추가 + [state.currentPlayerId, operation, roomStatus, renderStroke], ); const getAllDrawingData = useCallback((): CRDTSyncMessage | null => { From cb7be0dbfb81ec3155538688cc2d14da65f3f950 Mon Sep 17 00:00:00 2001 From: kwaksj329 Date: Tue, 3 Dec 2024 15:22:26 +0900 Subject: [PATCH 7/7] feat: Distinguish fill mode and pen mode in stopDrawing function --- client/src/hooks/canvas/useDrawing.ts | 31 ++++++++++++++++----------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/client/src/hooks/canvas/useDrawing.ts b/client/src/hooks/canvas/useDrawing.ts index d17160e25..721d7f540 100644 --- a/client/src/hooks/canvas/useDrawing.ts +++ b/client/src/hooks/canvas/useDrawing.ts @@ -116,8 +116,7 @@ export const useDrawing = ( (point: Point): CRDTUpdateMessage | null => { if (state.checkInkAvailability() === false || !state.crdtRef.current) return null; - state.currentStrokeIdsRef.current = []; - currentDrawingPoints.current = [point]; + currentDrawingPoints.current = state.drawingMode === DRAWING_MODE.PEN ? [point] : []; const drawingData = state.drawingMode === DRAWING_MODE.FILL @@ -175,22 +174,28 @@ export const useDrawing = ( ); const stopDrawing = useCallback(() => { - if (!state.crdtRef.current || !currentDrawingPoints.current || state.currentStrokeIdsRef.current.length === 0) - return; + if (!state.crdtRef.current || state.currentStrokeIdsRef.current.length === 0) return; if (state.historyPointerRef.current < state.strokeHistoryRef.current.length - 1) { state.strokeHistoryRef.current = state.strokeHistoryRef.current.slice(0, state.historyPointerRef.current + 1); } - const allPoints: Point[] = []; - state.currentStrokeIdsRef.current.forEach((strokeId) => { - const stroke = state.crdtRef.current!.strokes.find((s) => s.id === strokeId); - if (stroke) { - allPoints.push(...stroke.stroke.points); - } - }); - - const drawingData = createDrawingData(allPoints); + let drawingData: DrawingData; + + if (state.drawingMode === DRAWING_MODE.FILL) { + const stroke = state.crdtRef.current.strokes.find((s) => s.id === state.currentStrokeIdsRef.current[0])?.stroke; + if (!stroke) return; + drawingData = stroke; + } else { + const allPoints: Point[] = []; + state.currentStrokeIdsRef.current.forEach((strokeId) => { + const stroke = state.crdtRef.current!.strokes.find((s) => s.id === strokeId); + if (stroke) { + allPoints.push(...stroke.stroke.points); + } + }); + drawingData = createDrawingData(allPoints); + } state.strokeHistoryRef.current.push({ strokeIds: [...state.currentStrokeIdsRef.current],