diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index 45614aaa..89ec1580 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -6,7 +6,7 @@ import { Block as CRDTBlock } from "@noctaCrdt/Node"; import { serializedEditorDataProps } from "node_modules/@noctaCrdt/Interfaces.ts"; import { useRef, useState, useCallback, useEffect, useMemo } from "react"; import { useSocketStore } from "@src/stores/useSocketStore.ts"; -import { setCaretPosition } from "@src/utils/caretUtils.ts"; +import { setCaretPosition, getAbsoluteCaretPosition } from "@src/utils/caretUtils.ts"; import { editorContainer, editorTitleContainer, @@ -67,6 +67,9 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData }, [serializedEditorData, clientId]); const editorCRDT = useRef(editorCRDTInstance); + const isLocalChange = useRef(false); + const isSameLocalChange = useRef(false); + const composingCaret = useRef(null); // editorState도 editorCRDT가 변경될 때마다 업데이트 const [editorState, setEditorState] = useState({ @@ -84,13 +87,14 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData handleRemoteCharUpdate, handleRemoteCursor, addNewBlock, - } = useEditorOperation({ editorCRDT, pageId, setEditorState }); + } = useEditorOperation({ editorCRDT, pageId, setEditorState, isSameLocalChange }); const { sensors, handleDragEnd, handleDragStart } = useBlockDragAndDrop({ editorCRDT: editorCRDT.current, editorState, setEditorState, pageId, + isLocalChange, }); const { handleTypeSelect, handleAnimationSelect, handleCopySelect, handleDeleteSelect } = @@ -123,6 +127,7 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData pageId, onKeyDown, handleHrInput, + isLocalChange, }); const { onTextStyleUpdate, onTextColorUpdate, onTextBackgroundColorUpdate } = useTextOptionSelect( @@ -130,6 +135,7 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData editorCRDT: editorCRDT.current, setEditorState, pageId, + isLocalChange, }, ); @@ -137,6 +143,7 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData editorCRDT: editorCRDT.current, setEditorState, pageId, + isLocalChange, }); const handleTitleChange = (e: React.ChangeEvent) => { @@ -149,34 +156,76 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData const newTitle = e.target.value; if (newTitle === "") { setDisplayTitle(""); // 입력이 비어있으면 로컬상태는 빈 문자열로 - onTitleChange("새로운 페이지", true); // 서버에는 "새로운 페이지"로 저장 } else { onTitleChange(newTitle, true); } }; + 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); + }; + + 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 characters = [...event.data]; - const selection = window.getSelection(); - const caretPosition = selection?.focusOffset || 0; - const startPosition = caretPosition - characters.length; + const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; - characters.forEach((char, index) => { - const insertPosition = startPosition + index; - const charNode = block.crdt.localInsert(insertPosition, char, block.id, pageId); + if (!composingCaret.current) 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) return; + if (!currentCharNode) return; + currentCharNode.value = character; sendCharInsertOperation({ type: "charInsert", - node: charNode.node, + node: currentCharNode, blockId: block.id, pageId, }); - }); + if (space) { + const spaceNode = block.crdt.localInsert(currentCaret + 1, space, block.id, pageId); + sendCharInsertOperation({ + type: "charInsert", + node: spaceNode.node, + blockId: block.id, + pageId, + }); + } + block.crdt.currentCaret = currentCaret + 2; + } else { + // Windows의 경우 + const character = event.data; + if (!character) return; - block.crdt.currentCaret = caretPosition; + currentCharNode.value = character; + sendCharInsertOperation({ + type: "charInsert", + node: currentCharNode, + blockId: block.id, + pageId, + }); + sendCharInsertOperation(block.crdt.localInsert(currentCaret + 1, "", block.id, pageId)); + + block.crdt.currentCaret = currentCaret; + } + + composingCaret.current = null; }, [editorCRDT, pageId, sendCharInsertOperation], ); @@ -190,12 +239,17 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData if (activeElement?.tagName.toLowerCase() === "input") { return; // input에 포커스가 있으면 캐럿 위치 변경하지 않음 } - setCaretPosition({ - blockId: editorCRDT.current.currentBlock.id, - linkedList: editorCRDT.current.LinkedList, - position: editorCRDT.current.currentBlock?.crdt.currentCaret, - pageId, - }); + if (isLocalChange.current || isSameLocalChange.current) { + setCaretPosition({ + blockId: editorCRDT.current.currentBlock.id, + linkedList: editorCRDT.current.LinkedList, + position: editorCRDT.current.currentBlock?.crdt.currentCaret, + pageId, + }); + isLocalChange.current = false; + isSameLocalChange.current = false; + return; + } // 서윤님 피드백 반영 }, [editorCRDT.current.currentBlock?.id.serialize()]); @@ -300,6 +354,8 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData block={block} isActive={block.id === editorCRDT.current.currentBlock?.id} onInput={handleBlockInput} + onCompositionStart={handleCompositionStart} + onCompositionUpdate={handleCompositionUpdate} onCompositionEnd={handleCompositionEnd} onKeyDown={handleKeyDown} onCopy={handleCopy} diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index 4e165c18..ed5ddac5 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -32,6 +32,8 @@ interface BlockProps { dragBlockList: string[]; isActive: boolean; onInput: (e: React.FormEvent, block: CRDTBlock) => void; + onCompositionStart: (e: React.CompositionEvent, block: CRDTBlock) => void; + onCompositionUpdate: (e: React.CompositionEvent, block: CRDTBlock) => void; onCompositionEnd: (e: React.CompositionEvent, block: CRDTBlock) => void; onKeyDown: ( e: React.KeyboardEvent, @@ -72,6 +74,8 @@ export const Block: React.FC = memo( dragBlockList, isActive, onInput, + onCompositionStart, + onCompositionUpdate, onCompositionEnd, onKeyDown, onCopy, @@ -278,6 +282,8 @@ export const Block: React.FC = memo( onCopy={(e) => onCopy(e, blockRef.current, block)} onPaste={(e) => onPaste(e, blockRef.current, block)} onMouseUp={handleMouseUp} + onCompositionStart={(e) => onCompositionStart(e, block)} + onCompositionUpdate={(e) => onCompositionUpdate(e, block)} onCompositionEnd={(e) => onCompositionEnd(e, block)} contentEditable={block.type !== "hr"} spellCheck={false} diff --git a/client/src/features/editor/hooks/useBlockDragAndDrop.ts b/client/src/features/editor/hooks/useBlockDragAndDrop.ts index db65a6c7..517a5b1f 100644 --- a/client/src/features/editor/hooks/useBlockDragAndDrop.ts +++ b/client/src/features/editor/hooks/useBlockDragAndDrop.ts @@ -1,18 +1,19 @@ import { DragEndEvent, DragStartEvent, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { EditorCRDT } from "@noctaCrdt/Crdt"; import { Block } from "@noctaCrdt/Node"; -import { useSocketStore } from "@src/stores/useSocketStore.ts"; -import { EditorStateProps } from "../Editor"; import { RemoteBlockReorderOperation, RemoteBlockUpdateOperation, } from "node_modules/@noctaCrdt/Interfaces"; +import { useSocketStore } from "@src/stores/useSocketStore.ts"; +import { EditorStateProps } from "../Editor"; interface UseBlockDragAndDropProps { editorCRDT: EditorCRDT; editorState: EditorStateProps; setEditorState: React.Dispatch>; pageId: string; + isLocalChange: React.MutableRefObject; } export const useBlockDragAndDrop = ({ @@ -20,6 +21,7 @@ export const useBlockDragAndDrop = ({ editorState, setEditorState, pageId, + isLocalChange, }: UseBlockDragAndDropProps) => { const sensors = useSensors( useSensor(PointerSensor, { @@ -123,6 +125,7 @@ export const useBlockDragAndDrop = ({ if (disableDrag) return; try { + isLocalChange.current = true; const nodes = editorState.linkedList.spread(); // ID 문자열에서 client와 clock 추출 @@ -203,6 +206,7 @@ export const useBlockDragAndDrop = ({ ); if (parentIndex === -1) return []; + isLocalChange.current = true; const childBlockIds = []; diff --git a/client/src/features/editor/hooks/useBlockOperation.ts b/client/src/features/editor/hooks/useBlockOperation.ts index b414bf8d..2819ddc7 100644 --- a/client/src/features/editor/hooks/useBlockOperation.ts +++ b/client/src/features/editor/hooks/useBlockOperation.ts @@ -14,6 +14,7 @@ interface UseBlockOperationProps { setEditorState: React.Dispatch>; onKeyDown: (e: React.KeyboardEvent) => void; handleHrInput: (block: Block, content: string) => boolean; + isLocalChange: React.MutableRefObject; } export const useBlockOperation = ({ @@ -22,12 +23,14 @@ export const useBlockOperation = ({ setEditorState, onKeyDown, handleHrInput, + isLocalChange, }: UseBlockOperationProps) => { const { sendCharInsertOperation, sendCharDeleteOperation } = useSocketStore(); const handleBlockClick = useCallback( (blockId: BlockId, e: React.MouseEvent) => { if (editorCRDT) { + isLocalChange.current = true; const selection = window.getSelection(); if (!selection) return; @@ -52,6 +55,7 @@ export const useBlockOperation = ({ if ((e.nativeEvent as InputEvent).isComposing) { return; } + isLocalChange.current = true; let operationNode; const element = e.currentTarget; @@ -79,6 +83,7 @@ export const useBlockOperation = ({ currentContent.length - 1, ); } + console.log("prevChar", prevChar); const addedChar = newContent[newContent.length - 1]; charNode = block.crdt.localInsert( currentContent.length, @@ -236,13 +241,14 @@ export const useBlockOperation = ({ // 선택된 텍스트가 없으면 기본 키 핸들러 실행 if (selection.isCollapsed) { + isLocalChange.current = true; onKeyDown(e); return; } const range = selection.getRangeAt(0); if (!blockRef.contains(range.commonAncestorContainer)) return; - + isLocalChange.current = true; const startOffset = getTextOffset(blockRef, range.startContainer, range.startOffset); const endOffset = getTextOffset(blockRef, range.endContainer, range.endOffset); diff --git a/client/src/features/editor/hooks/useCopyAndPaste.ts b/client/src/features/editor/hooks/useCopyAndPaste.ts index db6df46f..74b442f1 100644 --- a/client/src/features/editor/hooks/useCopyAndPaste.ts +++ b/client/src/features/editor/hooks/useCopyAndPaste.ts @@ -17,9 +17,15 @@ interface UseCopyAndPasteProps { editorCRDT: EditorCRDT; pageId: string; setEditorState: React.Dispatch>; + isLocalChange: React.MutableRefObject; } -export const useCopyAndPaste = ({ editorCRDT, pageId, setEditorState }: UseCopyAndPasteProps) => { +export const useCopyAndPaste = ({ + editorCRDT, + pageId, + setEditorState, + isLocalChange, +}: UseCopyAndPasteProps) => { const { sendCharInsertOperation, sendCharDeleteOperation } = useSocketStore(); const handleCopy = useCallback( @@ -67,10 +73,14 @@ export const useCopyAndPaste = ({ editorCRDT, pageId, setEditorState }: UseCopyA if (!blockRef) return; const selection = window.getSelection(); - + isLocalChange.current = true; if (selection && !selection.isCollapsed) { const range = selection.getRangeAt(0); - if (!blockRef.contains(range.commonAncestorContainer)) return; + if (!blockRef.contains(range.commonAncestorContainer)) { + // ????? + isLocalChange.current = false; + return; + } const startOffset = getTextOffset(blockRef, range.startContainer, range.startOffset); const endOffset = getTextOffset(blockRef, range.endContainer, range.endOffset); diff --git a/client/src/features/editor/hooks/useEditorOperation.ts b/client/src/features/editor/hooks/useEditorOperation.ts index 84656b07..1cba996b 100644 --- a/client/src/features/editor/hooks/useEditorOperation.ts +++ b/client/src/features/editor/hooks/useEditorOperation.ts @@ -8,6 +8,8 @@ import { RemoteCharUpdateOperation, RemoteBlockInsertOperation, } from "@noctaCrdt/Interfaces"; +import { TextLinkedList } from "@noctaCrdt/LinkedList"; +import { CharId } from "@noctaCrdt/NodeId"; import { useCallback } from "react"; import { useSocketStore } from "@src/stores/useSocketStore"; import { EditorStateProps } from "../Editor"; @@ -16,12 +18,27 @@ interface UseEditorOperationProps { editorCRDT: React.MutableRefObject; pageId: string; setEditorState: (state: EditorStateProps) => void; + isSameLocalChange: React.MutableRefObject; } +const getPositionById = (linkedList: TextLinkedList, nodeId: CharId | null): number => { + if (!nodeId) return 0; + let position = 0; + let current = linkedList.head; + + while (current) { + if (current.equals(nodeId)) return position; + position += 1; + current = linkedList.getNode(current) ? linkedList.getNode(current)!.next : null; + } + return position; +}; + export const useEditorOperation = ({ editorCRDT, pageId, setEditorState, + isSameLocalChange, }: UseEditorOperationProps) => { const { sendBlockInsertOperation } = useSocketStore(); const handleRemoteBlockInsert = useCallback( @@ -59,11 +76,21 @@ export const useEditorOperation = ({ ); const handleRemoteCharInsert = useCallback( + // 원격으로 입력된 글자의 위치가 현재 캐럿의 위치보다 작을때만 캐럿을 1 증가시킨다. (operation: RemoteCharInsertOperation) => { if (operation.pageId !== pageId) return; const targetBlock = editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; if (targetBlock) { + if (targetBlock === editorCRDT.current.currentBlock) { + isSameLocalChange.current = true; + console.log("isSameLocalChange", isSameLocalChange.current); + } + const insertPosition = getPositionById(targetBlock.crdt.LinkedList, operation.node.prev); + const { currentCaret } = targetBlock.crdt; targetBlock.crdt.remoteInsert(operation); + if (editorCRDT.current.currentBlock === targetBlock && insertPosition < currentCaret) { + editorCRDT.current.currentBlock.crdt.currentCaret += 1; + } setEditorState({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, @@ -78,7 +105,15 @@ export const useEditorOperation = ({ if (operation.pageId !== pageId) return; const targetBlock = editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; if (targetBlock) { + if (targetBlock === editorCRDT.current.currentBlock) { + isSameLocalChange.current = true; + } + const deletePosition = getPositionById(targetBlock.crdt.LinkedList, operation.targetId); + const { currentCaret } = targetBlock.crdt; targetBlock.crdt.remoteDelete(operation); + if (editorCRDT.current.currentBlock === targetBlock && deletePosition < currentCaret) { + editorCRDT.current.currentBlock.crdt.currentCaret -= 1; + } setEditorState({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, diff --git a/client/src/features/editor/hooks/useTextOptions.ts b/client/src/features/editor/hooks/useTextOptions.ts index 52c7fa5c..0cb3a3f3 100644 --- a/client/src/features/editor/hooks/useTextOptions.ts +++ b/client/src/features/editor/hooks/useTextOptions.ts @@ -10,12 +10,14 @@ interface UseTextOptionSelectProps { editorCRDT: EditorCRDT; setEditorState: React.Dispatch>; pageId: string; + isLocalChange: React.MutableRefObject; } export const useTextOptionSelect = ({ editorCRDT, setEditorState, pageId, + isLocalChange, }: UseTextOptionSelectProps) => { const { sendCharUpdateOperation } = useSocketStore(); @@ -25,6 +27,7 @@ export const useTextOptionSelect = ({ const block = editorCRDT.LinkedList.getNode(blockId) as Block; if (!block) return; + isLocalChange.current = true; // 선택된 범위의 모든 문자들의 현재 스타일 상태 확인 const hasStyle = nodes.every((node) => { const char = block.crdt.LinkedList.getNode(node.id) as Char; @@ -82,6 +85,7 @@ export const useTextOptionSelect = ({ const block = editorCRDT.LinkedList.getNode(blockId) as Block; if (!block) return; + isLocalChange.current = true; nodes.forEach((node) => { const char = block.crdt.LinkedList.getNode(node.id) as Char; if (!char) return; @@ -114,6 +118,7 @@ export const useTextOptionSelect = ({ const block = editorCRDT.LinkedList.getNode(blockId) as Block; if (!block) return; + isLocalChange.current = true; nodes.forEach((node) => { const char = block.crdt.LinkedList.getNode(node.id) as Char; if (!char) return; diff --git a/client/src/features/editor/utils/domSyncUtils.ts b/client/src/features/editor/utils/domSyncUtils.ts index 53a4a4b9..bc65cdcf 100644 --- a/client/src/features/editor/utils/domSyncUtils.ts +++ b/client/src/features/editor/utils/domSyncUtils.ts @@ -139,7 +139,16 @@ const setsEqual = (a: Set, b: Set): boolean => { }; const sanitizeText = (text: string): string => { - return text.replace(/
/g, "\u00A0"); + return text.replace(/
/g, "\u00A0").replace(/[<>&"']/g, (match) => { + const escapeMap: Record = { + "<": "<", + ">": ">", + "&": "&", + '"': """, + "'": "'", + }; + return escapeMap[match] || match; + }); }; // 배열 비교 헬퍼 함수 diff --git a/client/src/stores/useSocketStore.ts b/client/src/stores/useSocketStore.ts index 03c450e2..4fb8fd5a 100644 --- a/client/src/stores/useSocketStore.ts +++ b/client/src/stores/useSocketStore.ts @@ -200,45 +200,45 @@ export const useSocketStore = create((set, get) => ({ }, sendBlockInsertOperation: (operation: RemoteBlockInsertOperation) => { - // const { socket } = get(); - // socket?.emit("insert/block", operation); - const { sendOperation } = get(); - sendOperation(operation); + const { socket } = get(); + socket?.emit("insert/block", operation); + // const { sendOperation } = get(); + // sendOperation(operation); }, sendCharInsertOperation: (operation: RemoteCharInsertOperation) => { - // const { socket } = get(); - // socket?.emit("insert/char", operation); - const { sendOperation } = get(); - sendOperation(operation); + const { socket } = get(); + socket?.emit("insert/char", operation); + // const { sendOperation } = get(); + // sendOperation(operation); }, sendBlockUpdateOperation: (operation: RemoteBlockUpdateOperation) => { - // const { socket } = get(); - // socket?.emit("update/block", operation); - const { sendOperation } = get(); - sendOperation(operation); + const { socket } = get(); + socket?.emit("update/block", operation); + // const { sendOperation } = get(); + // sendOperation(operation); }, sendBlockDeleteOperation: (operation: RemoteBlockDeleteOperation) => { - // const { socket } = get(); - // socket?.emit("delete/block", operation); - const { sendOperation } = get(); - sendOperation(operation); + const { socket } = get(); + socket?.emit("delete/block", operation); + // const { sendOperation } = get(); + // sendOperation(operation); }, sendCharDeleteOperation: (operation: RemoteCharDeleteOperation) => { - // const { socket } = get(); - // socket?.emit("delete/char", operation); - const { sendOperation } = get(); - sendOperation(operation); + const { socket } = get(); + socket?.emit("delete/char", operation); + // const { sendOperation } = get(); + // sendOperation(operation); }, sendCharUpdateOperation: (operation: RemoteCharUpdateOperation) => { - // const { socket } = get(); - // socket?.emit("update/char", operation); - const { sendOperation } = get(); - sendOperation(operation); + const { socket } = get(); + socket?.emit("update/char", operation); + // const { sendOperation } = get(); + // sendOperation(operation); }, sendCursorPosition: (position: CursorPosition) => { diff --git a/server/src/workspace/workspace.gateway.ts b/server/src/workspace/workspace.gateway.ts index 1d12ce66..273c092b 100644 --- a/server/src/workspace/workspace.gateway.ts +++ b/server/src/workspace/workspace.gateway.ts @@ -588,15 +588,16 @@ export class WorkspaceGateway implements OnGatewayInit, OnGatewayConnection, OnG } currentBlock.crdt.remoteInsert(data); + console.log("data", data); // server는 EditorCRDT 없습니다. - BlockCRDT 로 사용되고있음. const operation = { type: "charInsert", node: data.node, blockId: data.blockId, pageId: data.pageId, - style: data.style || [], - color: data.color ? data.color : "black", - backgroundColor: data.backgroundColor ? data.backgroundColor : "transparent", + style: data.node.style || [], + color: data.node.color ? data.node.color : "black", + backgroundColor: data.node.backgroundColor ? data.node.backgroundColor : "transparent", } as RemoteCharInsertOperation; this.emitOperation(client.id, data.pageId, "insert/char", operation, batch); } catch (error) {