diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index e181196..4f30996 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -1,22 +1,20 @@ -import { DndContext, DragEndEvent } from "@dnd-kit/core"; -import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { EditorCRDT } from "@noctaCrdt/Crdt"; import { BlockLinkedList } from "@noctaCrdt/LinkedList"; -import { Block as CRDTBlock } from "@noctaCrdt/Node"; import { serializedEditorDataProps } from "@noctaCrdt/types/Interfaces"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { useRef, useState, useCallback, useEffect, useMemo, memo } from "react"; +import { useRef, useState, useEffect, useMemo, memo } from "react"; import { useSocketStore } from "@src/stores/useSocketStore.ts"; -import { setCaretPosition, getAbsoluteCaretPosition } from "@src/utils/caretUtils.ts"; +import { setCaretPosition } from "@src/utils/caretUtils.ts"; import { editorContainer, addNewBlockButton } from "./Editor.style"; import { Block } from "./components/block/Block"; -import { useBlockDragAndDrop } from "./hooks/useBlockDragAndDrop"; import { useBlockOperation } from "./hooks/useBlockOperation.ts"; import { useBlockOptionSelect } from "./hooks/useBlockOption"; +import { useComposition } from "./hooks/useComposition.ts"; import { useCopyAndPaste } from "./hooks/useCopyAndPaste.ts"; -import { useEditorOperation } from "./hooks/useEditorOperation.ts"; import { useMarkdownGrammer } from "./hooks/useMarkdownGrammer"; import { useTextOptionSelect } from "./hooks/useTextOptions.ts"; +import { DndProvider } from "./provider/DndProvider.tsx"; +import { WebSocketProvider } from "./provider/WebsocketProvider.tsx"; export interface EditorStateProps { clock: number; @@ -35,13 +33,13 @@ export const Editor = memo(({ testKey, pageId, serializedEditorData }: EditorPro const { sendCharInsertOperation, sendCharDeleteOperation, - subscribeToRemoteOperations, sendBlockInsertOperation, sendBlockDeleteOperation, sendBlockUpdateOperation, sendBlockCheckboxOperation, + subscribeToRemoteOperations, + clientId, } = useSocketStore(); - const { clientId } = useSocketStore(); const [dragBlockList, setDragBlockList] = useState([]); const editorCRDTInstance = useMemo(() => { @@ -59,7 +57,6 @@ export const Editor = memo(({ testKey, pageId, serializedEditorData }: EditorPro const editorCRDT = useRef(editorCRDTInstance); const isLocalChange = useRef(false); const isSameLocalChange = useRef(false); - const composingCaret = useRef(null); // editorState도 editorCRDT가 변경될 때마다 업데이트 const [editorState, setEditorState] = useState({ @@ -67,27 +64,6 @@ export const Editor = memo(({ testKey, pageId, serializedEditorData }: EditorPro linkedList: editorCRDT.current.LinkedList, }); - const { - handleRemoteBlockInsert, - handleRemoteBlockDelete, - handleRemoteCharInsert, - handleRemoteCharDelete, - handleRemoteBlockUpdate, - handleRemoteBlockReorder, - handleRemoteCharUpdate, - handleRemoteCursor, - handleRemoteBlockCheckbox, - addNewBlock, - } = useEditorOperation({ editorCRDT, pageId, setEditorState, isSameLocalChange }); - - const { sensors, handleDragEnd, handleDragStart } = useBlockDragAndDrop({ - editorCRDT: editorCRDT.current, - editorState, - setEditorState, - pageId, - isLocalChange, - }); - const { handleTypeSelect, handleAnimationSelect, handleCopySelect, handleDeleteSelect } = useBlockOptionSelect({ editorCRDT: editorCRDT.current, @@ -143,103 +119,14 @@ export const Editor = memo(({ testKey, pageId, serializedEditorData }: EditorPro clientId, }); - const handleCompositionStart = (e: React.CompositionEvent, block: CRDTBlock) => { - const currentText = e.data; - composingCaret.current = getAbsoluteCaretPosition(e.currentTarget); - block.crdt.localInsert(composingCaret.current, currentText, block.id, pageId, clientId); - }; - - const handleCompositionUpdate = (e: React.CompositionEvent, block: CRDTBlock) => { - const currentText = e.data; - if (composingCaret.current === null) return; - const currentCaret = composingCaret.current; - const currentCharNode = block.crdt.LinkedList.findByIndex(currentCaret); - if (!currentCharNode) return; - currentCharNode.value = currentText; - }; - - const handleCompositionEnd = useCallback( - (e: React.CompositionEvent, block: CRDTBlock) => { - if (!editorCRDT) return; - const event = e.nativeEvent as CompositionEvent; - const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; - - if (composingCaret.current === null) return; - const currentCaret = composingCaret.current; - const currentCharNode = block.crdt.LinkedList.findByIndex(currentCaret); - if (!currentCharNode) return; - - if (isMac) { - const [character, space] = event.data; - if (!character || composingCaret.current === null) return; - if (!currentCharNode) return; - currentCharNode.value = character; - sendCharInsertOperation({ - type: "charInsert", - node: currentCharNode, - blockId: block.id, - pageId, - clientId, - }); - if (space) { - const spaceNode = block.crdt.localInsert( - currentCaret + 1, - space, - block.id, - pageId, - clientId, - ); - sendCharInsertOperation({ - type: "charInsert", - node: spaceNode.node, - blockId: block.id, - pageId, - clientId, - }); - } - block.crdt.currentCaret = currentCaret + 2; - } else { - // Windows의 경우 - const character = event.data; - if (!character) return; - - // 문자열을 개별 문자로 분리 - const characters = Array.from(character); - let currentPosition = currentCaret; - - // 각 문자에 대해 처리 - characters.forEach((char, index) => { - // 현재 위치의 노드 찾기 - const charNode = block.crdt.LinkedList.findByIndex(currentPosition); - if (!charNode) return; - - // 노드 값 설정 및 operation 전송 - charNode.value = char; - sendCharInsertOperation({ - type: "charInsert", - node: charNode, - blockId: block.id, - pageId, - clientId, - }); - - // 다음 문자를 위한 새 노드 생성 (마지막 문자가 아닌 경우에만) - if (index < characters.length - 1) { - block.crdt.localInsert(currentPosition + 1, "", block.id, pageId, clientId); - } - - currentPosition += 1; - }); - - block.crdt.currentCaret = currentCaret + characters.length; - } - isLocalChange.current = false; - isSameLocalChange.current = false; - }, - [editorCRDT, pageId, sendCharInsertOperation], - ); - - const subscriptionRef = useRef(false); + const { handleCompositionStart, handleCompositionUpdate, handleCompositionEnd } = useComposition({ + editorCRDT, + pageId, + clientId, + sendCharInsertOperation, + isLocalChange, + isSameLocalChange, + }); useEffect(() => { if (!editorCRDT || !editorCRDT.current.currentBlock) return; @@ -289,69 +176,17 @@ export const Editor = memo(({ testKey, pageId, serializedEditorData }: EditorPro } }, [editorCRDT.current.currentBlock?.id.serialize()]); - useEffect(() => { + const addNewBlock = () => { if (!editorCRDT) return; - if (subscriptionRef.current) return; - subscriptionRef.current = true; - - const unsubscribe = subscribeToRemoteOperations({ - onRemoteBlockInsert: handleRemoteBlockInsert, - onRemoteBlockDelete: handleRemoteBlockDelete, - onRemoteCharInsert: handleRemoteCharInsert, - onRemoteCharDelete: handleRemoteCharDelete, - onRemoteBlockUpdate: handleRemoteBlockUpdate, - onRemoteBlockReorder: handleRemoteBlockReorder, - onRemoteCharUpdate: handleRemoteCharUpdate, - onRemoteCursor: handleRemoteCursor, - onRemoteBlockCheckbox: handleRemoteBlockCheckbox, - onBatchOperations: (batch) => { - for (const item of batch) { - switch (item.event) { - case "insert/block": - handleRemoteBlockInsert(item.operation); - break; - case "delete/block": - handleRemoteBlockDelete(item.operation); - break; - case "insert/char": - handleRemoteCharInsert(item.operation); - break; - case "delete/char": - handleRemoteCharDelete(item.operation); - break; - case "update/block": - handleRemoteBlockUpdate(item.operation); - break; - case "reorder/block": - handleRemoteBlockReorder(item.operation); - break; - case "update/char": - handleRemoteCharUpdate(item.operation); - break; - default: - console.warn("알 수 없는 연산 타입:", item.event); - } - } - }, + const index = editorCRDT.current.LinkedList.spread().length; + const operation = editorCRDT.current.localInsert(index, ""); + editorCRDT.current.currentBlock = operation.node; + sendBlockInsertOperation({ type: "blockInsert", node: operation.node, pageId }); + setEditorState({ + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, }); - - return () => { - subscriptionRef.current = false; - unsubscribe?.(); - }; - }, [ - editorCRDT, - subscribeToRemoteOperations, - pageId, - handleRemoteBlockInsert, - handleRemoteBlockDelete, - handleRemoteCharInsert, - handleRemoteCharDelete, - handleRemoteBlockUpdate, - handleRemoteBlockReorder, - handleRemoteCharUpdate, - handleRemoteCursor, - ]); + }; // 로딩 상태 체크 if (!editorCRDT || !editorState) { @@ -365,20 +200,21 @@ export const Editor = memo(({ testKey, pageId, serializedEditorData }: EditorPro position: "relative", }} > - { - handleDragEnd(event, dragBlockList, () => setDragBlockList([])); - }} - onDragStart={(event) => { - handleDragStart(event, setDragBlockList); - }} - sensors={sensors} + - `${block.id.client}-${block.id.clock}`)} - strategy={verticalListSortingStrategy} + {virtualizer.getVirtualItems().map((virtualRow) => { const block = editorState.linkedList.spread()[virtualRow.index]; @@ -412,13 +248,17 @@ export const Editor = memo(({ testKey, pageId, serializedEditorData }: EditorPro /> ); })} - - - {editorState.linkedList.spread().length === 0 && ( -
- 클릭해서 새로운 블록을 추가하세요 -
- )} + + {editorState.linkedList.spread().length === 0 && ( +
+ 클릭해서 새로운 블록을 추가하세요 +
+ )} + ); diff --git a/client/src/features/editor/hooks/useComposition.ts b/client/src/features/editor/hooks/useComposition.ts new file mode 100644 index 0000000..a2405ce --- /dev/null +++ b/client/src/features/editor/hooks/useComposition.ts @@ -0,0 +1,125 @@ +import { EditorCRDT } from "@noctaCrdt/Crdt"; +import { Block } from "@noctaCrdt/Node"; +import { useCallback, useRef } from "react"; +import { getAbsoluteCaretPosition } from "@src/utils/caretUtils"; + +interface UseCompositionProps { + editorCRDT: React.MutableRefObject; + pageId: string; + clientId: number; + sendCharInsertOperation: (operation: any) => void; + isLocalChange: React.MutableRefObject; + isSameLocalChange: React.MutableRefObject; +} + +export const useComposition = ({ + editorCRDT, + pageId, + clientId, + sendCharInsertOperation, + isLocalChange, + isSameLocalChange, +}: UseCompositionProps) => { + const composingCaret = useRef(null); + const handleCompositionStart = (e: React.CompositionEvent, block: Block) => { + const currentText = e.data; + composingCaret.current = getAbsoluteCaretPosition(e.currentTarget); + block.crdt.localInsert(composingCaret.current, currentText, block.id, pageId, clientId); + }; + + const handleCompositionUpdate = (e: React.CompositionEvent, block: Block) => { + const currentText = e.data; + if (composingCaret.current === null) return; + const currentCaret = composingCaret.current; + const currentCharNode = block.crdt.LinkedList.findByIndex(currentCaret); + if (!currentCharNode) return; + currentCharNode.value = currentText; + }; + + const handleCompositionEnd = useCallback( + (e: React.CompositionEvent, block: Block) => { + if (!editorCRDT) return; + const event = e.nativeEvent as CompositionEvent; + const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; + + if (composingCaret.current === null) return; + const currentCaret = composingCaret.current; + const currentCharNode = block.crdt.LinkedList.findByIndex(currentCaret); + if (!currentCharNode) return; + + if (isMac) { + const [character, space] = event.data; + if (!character || composingCaret.current === null) return; + if (!currentCharNode) return; + currentCharNode.value = character; + sendCharInsertOperation({ + type: "charInsert", + node: currentCharNode, + blockId: block.id, + pageId, + clientId, + }); + if (space) { + const spaceNode = block.crdt.localInsert( + currentCaret + 1, + space, + block.id, + pageId, + clientId, + ); + sendCharInsertOperation({ + type: "charInsert", + node: spaceNode.node, + blockId: block.id, + pageId, + clientId, + }); + } + block.crdt.currentCaret = currentCaret + 2; + } else { + // Windows의 경우 + const character = event.data; + if (!character) return; + + // 문자열을 개별 문자로 분리 + const characters = Array.from(character); + let currentPosition = currentCaret; + + // 각 문자에 대해 처리 + characters.forEach((char, index) => { + // 현재 위치의 노드 찾기 + const charNode = block.crdt.LinkedList.findByIndex(currentPosition); + if (!charNode) return; + + // 노드 값 설정 및 operation 전송 + charNode.value = char; + sendCharInsertOperation({ + type: "charInsert", + node: charNode, + blockId: block.id, + pageId, + clientId, + }); + + // 다음 문자를 위한 새 노드 생성 (마지막 문자가 아닌 경우에만) + if (index < characters.length - 1) { + block.crdt.localInsert(currentPosition + 1, "", block.id, pageId, clientId); + } + + currentPosition += 1; + }); + + block.crdt.currentCaret = currentCaret + characters.length; + } + isLocalChange.current = false; + isSameLocalChange.current = false; + }, + [editorCRDT, pageId, sendCharInsertOperation], + ); + + return { + handleCompositionStart, + handleCompositionUpdate, + handleCompositionEnd, + }; +}; diff --git a/client/src/features/editor/hooks/useEditorOperation.ts b/client/src/features/editor/hooks/useEditorOperation.ts index e5b959f..4130008 100644 --- a/client/src/features/editor/hooks/useEditorOperation.ts +++ b/client/src/features/editor/hooks/useEditorOperation.ts @@ -12,7 +12,6 @@ import { RemoteBlockCheckboxOperation, } from "@noctaCrdt/types/Interfaces"; import { useCallback } from "react"; -import { useSocketStore } from "@src/stores/useSocketStore"; import { EditorStateProps } from "../Editor"; interface UseEditorOperationProps { @@ -41,7 +40,6 @@ export const useEditorOperation = ({ setEditorState, isSameLocalChange, }: UseEditorOperationProps) => { - const { sendBlockInsertOperation } = useSocketStore(); const handleRemoteBlockInsert = useCallback( (operation: RemoteBlockInsertOperation) => { if (operation.pageId !== pageId) return; @@ -183,18 +181,6 @@ export const useEditorOperation = ({ const handleRemoteCursor = useCallback(() => {}, []); - const addNewBlock = () => { - if (!editorCRDT) return; - const index = editorCRDT.current.LinkedList.spread().length; - const operation = editorCRDT.current.localInsert(index, ""); - editorCRDT.current.currentBlock = operation.node; - sendBlockInsertOperation({ type: "blockInsert", node: operation.node, pageId }); - setEditorState({ - clock: editorCRDT.current.clock, - linkedList: editorCRDT.current.LinkedList, - }); - }; - return { handleRemoteBlockInsert, handleRemoteBlockDelete, @@ -205,6 +191,5 @@ export const useEditorOperation = ({ handleRemoteCharUpdate, handleRemoteCursor, handleRemoteBlockCheckbox, - addNewBlock, }; }; diff --git a/client/src/features/editor/provider/DndProvider.tsx b/client/src/features/editor/provider/DndProvider.tsx new file mode 100644 index 0000000..da97d08 --- /dev/null +++ b/client/src/features/editor/provider/DndProvider.tsx @@ -0,0 +1,57 @@ +import { DndContext, DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { EditorCRDT } from "@noctaCrdt/Crdt"; +import { EditorStateProps } from "../Editor"; + +import { useBlockDragAndDrop } from "../hooks/useBlockDragAndDrop"; + +export interface DndProviderProps { + children: React.ReactNode; + editorCRDT: React.MutableRefObject; + pageId: string; + editorState: EditorStateProps; + setEditorState: any; + isLocalChange: React.MutableRefObject; + dragBlockList: string[]; + setDragBlockList: React.Dispatch>; +} + +export const DndProvider = ({ + children, + editorCRDT, + pageId, + editorState, + setEditorState, + isLocalChange, + dragBlockList, + setDragBlockList, +}: DndProviderProps) => { + const { sensors, handleDragEnd, handleDragStart } = useBlockDragAndDrop({ + editorCRDT: editorCRDT.current, + editorState, + setEditorState, + pageId, + isLocalChange, + }); + + return ( + { + handleDragEnd(event, dragBlockList, () => setDragBlockList([])); + }} + onDragStart={(event) => { + handleDragStart(event, setDragBlockList); + }} + sensors={sensors} + > + `${block.id.client}-${block.id.clock}`)} + strategy={verticalListSortingStrategy} + > + {children} + + + ); +}; diff --git a/client/src/features/editor/provider/WebsocketProvider.tsx b/client/src/features/editor/provider/WebsocketProvider.tsx new file mode 100644 index 0000000..8888814 --- /dev/null +++ b/client/src/features/editor/provider/WebsocketProvider.tsx @@ -0,0 +1,105 @@ +import { EditorCRDT } from "@noctaCrdt/Crdt"; +import { useRef, MutableRefObject, useEffect } from "react"; +import { RemoteOperationHandlers } from "@src/stores/useSocketStore.ts"; +import { useEditorOperation } from "../hooks/useEditorOperation"; + +export interface WebSocketProviderProps { + children: React.ReactNode; + editorCRDT: MutableRefObject; + pageId: string; + setEditorState: any; + isSameLocalChange: MutableRefObject; + subscribeToRemoteOperations: (handlers: RemoteOperationHandlers) => (() => void) | undefined; +} + +export const WebSocketProvider = ({ + children, + editorCRDT, + pageId, + setEditorState, + isSameLocalChange, + subscribeToRemoteOperations, +}: WebSocketProviderProps) => { + const subscriptionRef = useRef(false); + + const { + handleRemoteBlockInsert, + handleRemoteBlockDelete, + handleRemoteCharInsert, + handleRemoteCharDelete, + handleRemoteBlockUpdate, + handleRemoteBlockReorder, + handleRemoteCharUpdate, + handleRemoteCursor, + handleRemoteBlockCheckbox, + } = useEditorOperation({ editorCRDT, pageId, setEditorState, isSameLocalChange }); + + useEffect(() => { + if (!editorCRDT) return; + if (subscriptionRef.current) return; + subscriptionRef.current = true; + + const unsubscribe = subscribeToRemoteOperations({ + onRemoteBlockInsert: handleRemoteBlockInsert, + onRemoteBlockDelete: handleRemoteBlockDelete, + onRemoteCharInsert: handleRemoteCharInsert, + onRemoteCharDelete: handleRemoteCharDelete, + onRemoteBlockUpdate: handleRemoteBlockUpdate, + onRemoteBlockReorder: handleRemoteBlockReorder, + onRemoteCharUpdate: handleRemoteCharUpdate, + onRemoteCursor: handleRemoteCursor, + onRemoteBlockCheckbox: handleRemoteBlockCheckbox, + onBatchOperations: (batch) => { + for (const item of batch) { + switch (item.event) { + case "insert/block": + handleRemoteBlockInsert(item.operation); + break; + case "delete/block": + handleRemoteBlockDelete(item.operation); + break; + case "insert/char": + handleRemoteCharInsert(item.operation); + break; + case "delete/char": + handleRemoteCharDelete(item.operation); + break; + case "update/block": + handleRemoteBlockUpdate(item.operation); + break; + case "reorder/block": + handleRemoteBlockReorder(item.operation); + break; + case "update/char": + handleRemoteCharUpdate(item.operation); + break; + case "checkbox/block": + handleRemoteBlockCheckbox(item.operation); + break; + default: + console.warn("알 수 없는 연산 타입:", item.event); + } + } + }, + }); + + return () => { + subscriptionRef.current = false; + unsubscribe?.(); + }; + }, [ + editorCRDT, + subscribeToRemoteOperations, + pageId, + handleRemoteBlockInsert, + handleRemoteBlockDelete, + handleRemoteCharInsert, + handleRemoteCharDelete, + handleRemoteBlockUpdate, + handleRemoteBlockReorder, + handleRemoteCharUpdate, + handleRemoteCursor, + ]); + + return <>{children}; +}; diff --git a/client/src/stores/useSocketStore.ts b/client/src/stores/useSocketStore.ts index 7639af3..92d1966 100644 --- a/client/src/stores/useSocketStore.ts +++ b/client/src/stores/useSocketStore.ts @@ -83,7 +83,7 @@ interface SocketStore { getSocketId: () => string | null; } -interface RemoteOperationHandlers { +export interface RemoteOperationHandlers { onRemoteBlockUpdate: (operation: RemoteBlockUpdateOperation) => void; onRemoteBlockInsert: (operation: RemoteBlockInsertOperation) => void; onRemoteBlockDelete: (operation: RemoteBlockDeleteOperation) => void;