diff --git a/@noctaCrdt/Interfaces.ts b/@noctaCrdt/Interfaces.ts index 63fba8a8..82659583 100644 --- a/@noctaCrdt/Interfaces.ts +++ b/@noctaCrdt/Interfaces.ts @@ -113,6 +113,13 @@ export interface RemoteBlockDeleteOperation { pageId: string; } +export interface RemoteBlockCheckboxOperation { + type: "blockCheckbox"; + blockId: BlockId; + isChecked: boolean; + pageId: string; +} + export interface RemoteCharDeleteOperation { type: "charDelete"; targetId: CharId; diff --git a/@noctaCrdt/Node.ts b/@noctaCrdt/Node.ts index d7220560..c0f6104e 100644 --- a/@noctaCrdt/Node.ts +++ b/@noctaCrdt/Node.ts @@ -51,6 +51,7 @@ export class Block extends Node { icon: string; crdt: BlockCRDT; listIndex?: number; + isChecked?: boolean; constructor(value: string, id: BlockId) { super(value, id); @@ -72,6 +73,7 @@ export class Block extends Node { icon: this.icon, crdt: this.crdt.serialize(), listIndex: this.listIndex ? this.listIndex : null, + isChecked: this.isChecked ? this.isChecked : null, }; } @@ -87,6 +89,7 @@ export class Block extends Node { block.icon = data.icon; block.crdt = BlockCRDT.deserialize(data.crdt); block.listIndex = data.listIndex ? data.listIndex : null; + block.isChecked = data.isChecked ? data.isChecked : null; return block; } } diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index fe8e25d9..312adffd 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -42,6 +42,7 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData sendBlockInsertOperation, sendBlockDeleteOperation, sendBlockUpdateOperation, + sendBlockCheckboxOperation, } = useSocketStore(); const { clientId } = useSocketStore(); const [displayTitle, setDisplayTitle] = useState(pageTitle); @@ -86,6 +87,7 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData handleRemoteBlockReorder, handleRemoteCharUpdate, handleRemoteCursor, + handleRemoteBlockCheckbox, addNewBlock, } = useEditorOperation({ editorCRDT, pageId, setEditorState, isSameLocalChange }); @@ -121,14 +123,16 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData sendCharInsertOperation, }); - const { handleBlockClick, handleBlockInput, handleKeyDown } = useBlockOperation({ - editorCRDT: editorCRDT.current, - setEditorState, - pageId, - onKeyDown, - handleHrInput, - isLocalChange, - }); + const { handleBlockClick, handleBlockInput, handleKeyDown, handleCheckboxToggle } = + useBlockOperation({ + editorCRDT: editorCRDT.current, + setEditorState, + pageId, + onKeyDown, + handleHrInput, + isLocalChange, + sendBlockCheckboxOperation, + }); const { onTextStyleUpdate, onTextColorUpdate, onTextBackgroundColorUpdate } = useTextOptionSelect( { @@ -275,6 +279,7 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData onRemoteBlockReorder: handleRemoteBlockReorder, onRemoteCharUpdate: handleRemoteCharUpdate, onRemoteCursor: handleRemoteCursor, + onRemoteBlockCheckbox: handleRemoteBlockCheckbox, onBatchOperations: (batch) => { for (const item of batch) { switch (item.event) { @@ -377,6 +382,7 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData onTextColorUpdate={onTextColorUpdate} onTextBackgroundColorUpdate={onTextBackgroundColorUpdate} dragBlockList={dragBlockList} + onCheckboxToggle={handleCheckboxToggle} /> ))} diff --git a/client/src/features/editor/components/IconBlock/IconBlock.style.ts b/client/src/features/editor/components/IconBlock/IconBlock.style.ts index be2c8720..2a95384b 100644 --- a/client/src/features/editor/components/IconBlock/IconBlock.style.ts +++ b/client/src/features/editor/components/IconBlock/IconBlock.style.ts @@ -32,5 +32,15 @@ export const iconStyle = cva({ backgroundColor: "white", }, }, + isChecked: { + true: { + color: "white", + backgroundColor: "#7272FF", + }, + false: { + color: "gray.600", + backgroundColor: "white", + }, + }, }, }); diff --git a/client/src/features/editor/components/IconBlock/IconBlock.tsx b/client/src/features/editor/components/IconBlock/IconBlock.tsx index 70c466db..61c0632e 100644 --- a/client/src/features/editor/components/IconBlock/IconBlock.tsx +++ b/client/src/features/editor/components/IconBlock/IconBlock.tsx @@ -5,9 +5,17 @@ interface IconBlockProps { type: ElementType; index: number | undefined; indent?: number; + isChecked?: boolean; + onCheckboxClick?: () => void; } -export const IconBlock = ({ type, index = 1, indent = 0 }: IconBlockProps) => { +export const IconBlock = ({ + type, + index = 1, + indent = 0, + isChecked = false, + onCheckboxClick, +}: IconBlockProps) => { const getIcon = () => { switch (type) { case "ul": @@ -21,7 +29,17 @@ export const IconBlock = ({ type, index = 1, indent = 0 }: IconBlockProps) => { case "ol": return {`${index}.`}; case "checkbox": - return ; + return ( + + {isChecked ? "✓" : ""} + + ); default: return null; } diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index 6d0300e9..5d398792 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -66,6 +66,7 @@ interface BlockProps { blockId: BlockId, nodes: Array, ) => void; + onCheckboxToggle: (blockId: BlockId, isChecked: boolean) => void; } export const Block: React.FC = memo( ({ @@ -88,6 +89,7 @@ export const Block: React.FC = memo( onTextStyleUpdate, onTextColorUpdate, onTextBackgroundColorUpdate, + onCheckboxToggle, }: BlockProps) => { const blockRef = useRef(null); const { isOpen, openModal, closeModal } = useModal(); @@ -267,6 +269,10 @@ export const Block: React.FC = memo( } }; + const handleCheckboxClick = () => { + onCheckboxToggle(block.id, !block.isChecked); + }; + const Indicator = () => (
= memo( onCopySelect={handleCopySelect} onDeleteSelect={handleDeleteSelect} /> - + +
handleKeyDown(e, blockRef.current, block)} diff --git a/client/src/features/editor/hooks/useBlockOperation.ts b/client/src/features/editor/hooks/useBlockOperation.ts index 34f707b6..240a7324 100644 --- a/client/src/features/editor/hooks/useBlockOperation.ts +++ b/client/src/features/editor/hooks/useBlockOperation.ts @@ -1,5 +1,5 @@ import { EditorCRDT } from "@noctaCrdt/Crdt"; -import { RemoteCharInsertOperation } from "@noctaCrdt/Interfaces"; +import { RemoteBlockCheckboxOperation, RemoteCharInsertOperation } from "@noctaCrdt/Interfaces"; import { Block } from "@noctaCrdt/Node"; import { BlockId } from "@noctaCrdt/NodeId"; import { useCallback } from "react"; @@ -15,6 +15,7 @@ interface UseBlockOperationProps { onKeyDown: (e: React.KeyboardEvent) => void; handleHrInput: (block: Block, content: string) => boolean; isLocalChange: React.MutableRefObject; + sendBlockCheckboxOperation: (operation: RemoteBlockCheckboxOperation) => void; } export const useBlockOperation = ({ @@ -24,6 +25,7 @@ export const useBlockOperation = ({ onKeyDown, handleHrInput, isLocalChange, + sendBlockCheckboxOperation, }: UseBlockOperationProps) => { const { sendCharInsertOperation, sendCharDeleteOperation } = useSocketStore(); @@ -257,9 +259,32 @@ export const useBlockOperation = ({ [editorCRDT.LinkedList, sendCharDeleteOperation, pageId, onKeyDown], ); + const handleCheckboxToggle = useCallback( + (blockId: BlockId, isChecked: boolean) => { + const operation = { + type: "blockCheckbox", + blockId, + pageId, + isChecked, + } as RemoteBlockCheckboxOperation; + + sendBlockCheckboxOperation(operation); + const targetBlock = editorCRDT.LinkedList.nodeMap[JSON.stringify(blockId)]; + if (targetBlock) { + targetBlock.isChecked = isChecked; + setEditorState({ + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, + }); + } + }, + [editorCRDT, pageId, sendBlockCheckboxOperation], + ); + return { handleBlockClick, handleBlockInput, handleKeyDown, + handleCheckboxToggle, }; }; diff --git a/client/src/features/editor/hooks/useEditorOperation.ts b/client/src/features/editor/hooks/useEditorOperation.ts index 1a96758f..8fd4616d 100644 --- a/client/src/features/editor/hooks/useEditorOperation.ts +++ b/client/src/features/editor/hooks/useEditorOperation.ts @@ -7,6 +7,7 @@ import { RemoteBlockReorderOperation, RemoteCharUpdateOperation, RemoteBlockInsertOperation, + RemoteBlockCheckboxOperation, } from "@noctaCrdt/Interfaces"; import { TextLinkedList } from "@noctaCrdt/LinkedList"; import { CharId } from "@noctaCrdt/NodeId"; @@ -151,6 +152,21 @@ export const useEditorOperation = ({ [pageId, editorCRDT], ); + const handleRemoteBlockCheckbox = useCallback( + (operation: RemoteBlockCheckboxOperation) => { + if (operation.pageId !== pageId) return; + const targetBlock = editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; + if (targetBlock) { + targetBlock.isChecked = operation.isChecked; + setEditorState({ + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, + }); + } + }, + [pageId, editorCRDT], + ); + const handleRemoteCharUpdate = useCallback( (operation: RemoteCharUpdateOperation) => { if (!editorCRDT) return; @@ -190,6 +206,7 @@ export const useEditorOperation = ({ handleRemoteBlockReorder, handleRemoteCharUpdate, handleRemoteCursor, + handleRemoteBlockCheckbox, addNewBlock, }; }; diff --git a/client/src/stores/useSocketStore.ts b/client/src/stores/useSocketStore.ts index 22a19adb..41ce6599 100644 --- a/client/src/stores/useSocketStore.ts +++ b/client/src/stores/useSocketStore.ts @@ -6,6 +6,7 @@ import { RemoteCharDeleteOperation, RemoteBlockUpdateOperation, RemoteBlockReorderOperation, + RemoteBlockCheckboxOperation, RemoteCharUpdateOperation, RemotePageDeleteOperation, RemotePageUpdateOperation, @@ -78,6 +79,7 @@ interface SocketStore { subscribeToPageOperations: (handlers: PageOperationsHandlers) => (() => void) | undefined; setWorkspace: (workspace: WorkSpaceSerializedProps) => void; sendOperation: (operation: any) => void; + sendBlockCheckboxOperation: (operation: RemoteBlockCheckboxOperation) => void; } interface RemoteOperationHandlers { @@ -90,6 +92,7 @@ interface RemoteOperationHandlers { onRemoteCharUpdate: (operation: RemoteCharUpdateOperation) => void; onRemoteCursor: (position: CursorPosition) => void; onBatchOperations: (batch: any[]) => void; + onRemoteBlockCheckbox: (operation: RemoteBlockCheckboxOperation) => void; } interface PageOperationsHandlers { @@ -292,6 +295,11 @@ export const useSocketStore = create((set, get) => ({ // sendOperation(operation); }, + sendBlockCheckboxOperation: (operation: RemoteBlockCheckboxOperation) => { + const { socket } = get(); + socket?.emit("checkbox/block", operation); + }, + subscribeToRemoteOperations: (handlers: RemoteOperationHandlers) => { const { socket } = get(); if (!socket) return; @@ -305,6 +313,7 @@ export const useSocketStore = create((set, get) => ({ socket.on("update/char", handlers.onRemoteCharUpdate); socket.on("cursor", handlers.onRemoteCursor); socket.on("batch/operations", handlers.onBatchOperations); + socket.on("checkbox/block", handlers.onRemoteBlockCheckbox); return () => { socket.off("update/block", handlers.onRemoteBlockUpdate); @@ -316,6 +325,7 @@ export const useSocketStore = create((set, get) => ({ socket.off("update/char", handlers.onRemoteCharUpdate); socket.off("cursor", handlers.onRemoteCursor); socket.off("batch/operations", handlers.onBatchOperations); + socket.off("checkbox/block", handlers.onRemoteBlockCheckbox); }; }, diff --git a/server/src/workspace/workspace.gateway.ts b/server/src/workspace/workspace.gateway.ts index 541e27bf..4a76a06f 100644 --- a/server/src/workspace/workspace.gateway.ts +++ b/server/src/workspace/workspace.gateway.ts @@ -20,6 +20,7 @@ import { RemoteBlockUpdateOperation, RemotePageCreateOperation, RemoteBlockReorderOperation, + RemoteBlockCheckboxOperation, RemoteCharUpdateOperation, CursorPosition, } from "@noctaCrdt/Interfaces"; @@ -719,6 +720,50 @@ export class WorkspaceGateway implements OnGatewayInit, OnGatewayConnection, OnG } } + /** + * 블록 Checkbox 연산 처리 + */ + @SubscribeMessage("checkbox/block") + async handleBlockCheckbox( + @MessageBody() data: RemoteBlockCheckboxOperation, + @ConnectedSocket() client: Socket, + ): Promise { + const clientInfo = this.clientMap.get(client.id); + try { + this.logger.debug( + `Block checkbox 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, + JSON.stringify(data), + ); + const { workspaceId } = client.data; + const currentBlock = await this.workSpaceService.getBlock( + workspaceId, + data.pageId, + data.blockId, + ); + + if (!currentBlock) { + throw new Error(`Block with id ${data.blockId} not found`); + } + + currentBlock.isChecked = data.isChecked; + + const operation = { + type: "blockCheckbox", + blockId: data.blockId, + pageId: data.pageId, + isChecked: data.isChecked, + }; + + client.broadcast.to(data.pageId).emit("checkbox/block", operation); + } catch (error) { + this.logger.error( + `Block Checkbox 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, + error.stack, + ); + throw new WsException(`Checkbox 연산 실패: ${error.message}`); + } + } + /** * 글자 삽입 연산 처리 */