From 9073feac2c9d425a98c6a44bc958aa893a600757 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sat, 23 Nov 2024 03:49:32 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20localUpdate=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- @noctaCrdt/Crdt.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/@noctaCrdt/Crdt.ts b/@noctaCrdt/Crdt.ts index 34812d0c..674c25a0 100644 --- a/@noctaCrdt/Crdt.ts +++ b/@noctaCrdt/Crdt.ts @@ -103,6 +103,17 @@ export class EditorCRDT extends CRDT { return operation; } + localUpdate(block: Block, pageId: string): RemoteBlockUpdateOperation { + const updatedBlock = this.LinkedList.nodeMap[JSON.stringify(block.id)]; + updatedBlock.animation = block.animation; + updatedBlock.icon = block.icon; + updatedBlock.indent = block.indent; + updatedBlock.style = block.style; + updatedBlock.type = block.type; + // this.LinkedList.nodeMap[JSON.stringify(block.id)] = block; + return { node: updatedBlock, pageId }; + } + remoteUpdate(block: Block, pageId: string): RemoteBlockUpdateOperation { const updatedBlock = this.LinkedList.nodeMap[JSON.stringify(block.id)]; updatedBlock.animation = block.animation; From 2ba1b3d98ab2caadc3f96126726c1f8fa12b9ba3 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sat, 23 Nov 2024 03:50:16 +0900 Subject: [PATCH 02/10] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20console.log=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20impo?= =?UTF-8?q?rt=20=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- @noctaCrdt/LinkedList.ts | 2 -- client/src/App.tsx | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/@noctaCrdt/LinkedList.ts b/@noctaCrdt/LinkedList.ts index 5adfcef6..74a1109e 100644 --- a/@noctaCrdt/LinkedList.ts +++ b/@noctaCrdt/LinkedList.ts @@ -27,8 +27,6 @@ export abstract class LinkedList> { deleteNode(id: T["id"]): void { const nodeToDelete = this.getNode(id); - console.log(this.nodeMap); - console.log("nodeToDelete", nodeToDelete, id); if (!nodeToDelete) return; if (this.head && id.equals(this.head)) { diff --git a/client/src/App.tsx b/client/src/App.tsx index 8878f7b0..98190cc3 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,9 +1,9 @@ -import { useEffect } from "react"; import { useRefreshQuery } from "@apis/auth"; -import { ErrorModal } from "@components/modal/ErrorModal"; -import { WorkSpace } from "@features/workSpace/WorkSpace"; import { useErrorStore } from "@stores/useErrorStore"; import { useUserInfo } from "@stores/useUserStore"; +import { useEffect } from "react"; +import { ErrorModal } from "@components/modal/ErrorModal"; +import { WorkSpace } from "@features/workSpace/WorkSpace"; import { useSocketStore } from "./stores/useSocketStore"; const App = () => { From f9b95b6b0b974dc1dcb9f0b4e93c8e7e1f85f11c Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sat, 23 Nov 2024 03:50:36 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=EC=BA=90=EB=9F=BF=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/utils/caretUtils.ts | 71 ++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 client/src/utils/caretUtils.ts diff --git a/client/src/utils/caretUtils.ts b/client/src/utils/caretUtils.ts new file mode 100644 index 00000000..577a4015 --- /dev/null +++ b/client/src/utils/caretUtils.ts @@ -0,0 +1,71 @@ +import { BlockLinkedList, TextLinkedList } from "@noctaCrdt/LinkedList"; +import { BlockId } from "@noctaCrdt/NodeId"; + +interface SetCaretPositionProps { + blockId: BlockId; + linkedList: BlockLinkedList | TextLinkedList; + clientX?: number; + clientY?: number; + position?: number; // 특정 위치로 캐럿을 설정하고 싶을 때 사용 +} + +export const setCaretPosition = ({ + blockId, + linkedList, + clientX, + clientY, + position, +}: SetCaretPositionProps): boolean => { + try { + const selection = window.getSelection(); + if (!selection) return false; + + const blockElements = Array.from( + document.querySelectorAll('.d_flex.pos_relative.w_full[data-group="true"]'), + ); + const currentIndex = linkedList.spread().findIndex((b) => b.id === blockId); + const targetElement = blockElements[currentIndex]; + if (!targetElement) return false; + + const editableDiv = targetElement.querySelector('[contenteditable="true"]') as HTMLDivElement; + if (!editableDiv) return false; + + editableDiv.focus(); + + let range: Range; + + if (clientX !== undefined && clientY !== undefined) { + // 클릭 위치에 따른 캐럿 설정 + const clickRange = document.caretRangeFromPoint(clientX, clientY); + if (!clickRange) return false; + range = clickRange; + } else if (position !== undefined) { + // 특정 위치에 캐럿 설정 + range = document.createRange(); + const textNode = + Array.from(editableDiv.childNodes).find((node) => node.nodeType === Node.TEXT_NODE) || null; + if (!textNode) { + // 텍스트 노드가 없으면 새로운 텍스트 노드를 추가 + const newTextNode = document.createTextNode(""); + editableDiv.appendChild(newTextNode); + range.setStart(newTextNode, 0); + } else { + // position이 텍스트 길이를 초과하지 않도록 조정 + const safePosition = Math.min(position, textNode.textContent?.length || 0); + range.setStart(textNode, safePosition); + } + + range.collapse(true); + } else { + return false; + } + + selection.removeAllRanges(); + selection.addRange(range); + + return true; + } catch (error) { + console.error("Error setting caret position:", error); + return false; + } +}; From e6a9a319905f7de7eda9ea9ce9230171d6038415 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sat, 23 Nov 2024 03:54:51 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20editorState=20=EB=B0=8F=20?= =?UTF-8?q?=EC=BA=90=EB=9F=BF=20=EA=B4=80=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - currentBlock 제거 - 캐럿 이동시 requestAnimationFrame과 useLayoutEffect로 캐럿 위치 수정 --- client/src/features/editor/Editor.tsx | 94 +++++++++++++-------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index f8424eb7..21c15a26 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -8,8 +8,9 @@ import { RemoteCharInsertOperation, serializedEditorDataProps, } from "node_modules/@noctaCrdt/Interfaces.ts"; -import { useRef, useState, useCallback, useEffect, useMemo } from "react"; +import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from "react"; import { useSocketStore } from "@src/stores/useSocketStore.ts"; +import { setCaretPosition } from "@src/utils/caretUtils.ts"; import { editorContainer, editorTitleContainer, editorTitle } from "./Editor.style"; import { Block } from "./components/block/Block.tsx"; import { useBlockDragAndDrop } from "./hooks/useBlockDragAndDrop"; @@ -24,7 +25,6 @@ interface EditorProps { export interface EditorStateProps { clock: number; linkedList: BlockLinkedList; - currentBlock: BlockId | null; } // TODO: pageId, editorCRDT를 props로 받아와야함 export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorProps) => { @@ -46,7 +46,6 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr const [editorState, setEditorState] = useState({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, - currentBlock: null as BlockId | null, }); const { sensors, handleDragEnd } = useBlockDragAndDrop({ editorCRDT: editorCRDT.current, @@ -71,37 +70,9 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onTitleChange(e.target.value); }; - const handleBlockClick = (blockId: BlockId, e: React.MouseEvent) => { - try { - const block = editorState.linkedList.getNode(blockId); - if (!block) { - console.warn("Block not found:", blockId); - return; - } - - const selection = window.getSelection(); - const range = document.caretRangeFromPoint(e.clientX, e.clientY); - - if (!selection || !range) { - console.warn("Selection or range not available"); - return; - } - - // 새로운 Range로 Selection 설정 - selection.removeAllRanges(); - selection.addRange(range); - - // 현재 캐럿 위치를 저장 - const caretPosition = selection.focusOffset; - block.crdt.currentCaret = caretPosition; - - setEditorState((prev) => ({ - ...prev, - currentBlock: blockId, - })); - } catch (error) { - console.error("Error handling block click:", error); - } + const handleBlockClick = (blockId: BlockId) => { + editorCRDT.current.currentBlock = + editorCRDT.current.LinkedList.nodeMap[JSON.stringify(blockId)]; }; const handleBlockInput = useCallback( @@ -119,28 +90,54 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr if (caretPosition === 0) { const [addedChar] = newContent; charNode = block.crdt.localInsert(0, addedChar, block.id, pageId); - block.crdt.currentCaret = 1; + editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; + requestAnimationFrame(() => { + setCaretPosition({ + blockId: block.id, + linkedList: editorCRDT.current.LinkedList, + position: caretPosition, + }); + }); } else if (caretPosition > currentContent.length) { const addedChar = newContent[newContent.length - 1]; charNode = block.crdt.localInsert(currentContent.length, addedChar, block.id, pageId); - block.crdt.currentCaret = caretPosition; + editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; + requestAnimationFrame(() => { + setCaretPosition({ + blockId: block.id, + linkedList: editorCRDT.current.LinkedList, + position: caretPosition, + }); + }); } else { const addedChar = newContent[caretPosition - 1]; charNode = block.crdt.localInsert(caretPosition - 1, addedChar, block.id, pageId); - block.crdt.currentCaret = caretPosition; + editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; + requestAnimationFrame(() => { + setCaretPosition({ + blockId: block.id, + linkedList: editorCRDT.current.LinkedList, + position: caretPosition, + }); + }); } sendCharInsertOperation({ node: charNode.node, blockId: block.id, pageId }); } else if (newContent.length < currentContent.length) { // 문자가 삭제된 경우 operationNode = block.crdt.localDelete(caretPosition, block.id, pageId); - block.crdt.currentCaret = caretPosition; sendCharDeleteOperation(operationNode); + editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; + requestAnimationFrame(() => { + setCaretPosition({ + blockId: block.id, + linkedList: editorCRDT.current.LinkedList, + position: caretPosition, + }); + }); } - setEditorState((prev) => ({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, - currentBlock: prev.currentBlock, })); }, [sendCharInsertOperation, sendCharDeleteOperation], @@ -148,6 +145,15 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr const subscriptionRef = useRef(false); + useLayoutEffect(() => { + if (!editorCRDT.current.currentBlock) return; + setCaretPosition({ + blockId: editorCRDT.current.currentBlock.id, + linkedList: editorCRDT.current.LinkedList, + position: editorCRDT.current.currentBlock?.crdt.currentCaret, + }); + }, [editorCRDT.current.currentBlock?.crdt.read().length]); + useEffect(() => { if (subscriptionRef.current) return; subscriptionRef.current = true; @@ -160,7 +166,6 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr setEditorState((prev) => ({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, - currentBlock: prev.currentBlock, })); }, @@ -171,7 +176,6 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr setEditorState((prev) => ({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, - currentBlock: prev.currentBlock, })); }, @@ -184,7 +188,6 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr setEditorState((prev) => ({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, - currentBlock: prev.currentBlock, })); }, @@ -197,7 +200,6 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr setEditorState((prev) => ({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, - currentBlock: prev.currentBlock, })); }, @@ -210,7 +212,6 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr setEditorState((prev) => ({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, - currentBlock: prev.currentBlock, })); }, @@ -221,7 +222,6 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr setEditorState((prev) => ({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, - currentBlock: prev.currentBlock, })); }, @@ -271,7 +271,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr key={`${block.id.client}-${block.id.clock}`} id={`${block.id.client}-${block.id.clock}`} block={block} - isActive={block.id === editorState.currentBlock} + isActive={block.id === editorCRDT.current.currentBlock?.id} onInput={handleBlockInput} onKeyDown={handleKeyDown} onClick={handleBlockClick} From ec34926352e4d2f3ea45e780593f2ae60e660983 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sat, 23 Nov 2024 03:55:32 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20block=20=EC=BA=90=EB=9F=BF=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 캐럿은 editor에서 관리 --- .../editor/components/block/Block.tsx | 32 ++----------------- 1 file changed, 3 insertions(+), 29 deletions(-) diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index ea732375..0e27c978 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -3,7 +3,7 @@ import { CSS } from "@dnd-kit/utilities"; import { Block as CRDTBlock } from "@noctaCrdt/Node"; import { BlockId } from "@noctaCrdt/NodeId"; import { motion } from "framer-motion"; -import { memo, useRef, useLayoutEffect } from "react"; +import { memo, useRef, useLayoutEffect, useState } from "react"; import { useBlockAnimation } from "../../hooks/useBlockAnimtaion"; import { IconBlock } from "../IconBlock/IconBlock"; import { MenuBlock } from "../MenuBlock/MenuBlock"; @@ -16,12 +16,11 @@ interface BlockProps { isActive: boolean; onInput: (e: React.FormEvent, block: CRDTBlock) => void; onKeyDown: (e: React.KeyboardEvent) => void; - onClick: (blockId: BlockId, e: React.MouseEvent) => void; + onClick: (blockId: BlockId) => void; } export const Block: React.FC = memo( ({ id, block, isActive, onInput, onKeyDown, onClick }: BlockProps) => { - console.log("블록 초기화 상태", block); const blockRef = useRef(null); const blockCRDTRef = useRef(block); @@ -38,31 +37,6 @@ export const Block: React.FC = memo( onInput(e, block); }; - const setFocusAndCursor = () => { - if (blockRef.current && isActive) { - const selection = window.getSelection(); - if (!selection) return; - const range = document.createRange(); - const content = - blockRef.current.firstChild || blockRef.current.appendChild(document.createTextNode("")); - // const position = Math.min(block.crdt.currentCaret, content.textContent?.length || 0); - const position = Math.min( - blockCRDTRef.current.crdt.currentCaret, - content.textContent?.length || 0, - ); - range.setStart(content, position); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - } - }; - - useLayoutEffect(() => { - // ✅ 추가 - setFocusAndCursor(); - // block.crdt.currentCaret - }, [isActive, blockCRDTRef.current.crdt.currentCaret]); - return ( // TODO: eslint 규칙을 수정해야 할까? // TODO: ol일때 index 순서 처리 @@ -88,7 +62,7 @@ export const Block: React.FC = memo( ref={blockRef} onKeyDown={onKeyDown} onInput={handleInput} - onClick={(e) => onClick(block.id, e)} + onClick={(e) => onClick(block.id)} contentEditable suppressContentEditableWarning className={textContainerStyle({ From e62f1def42d217c323b8b9e7f00098adb829de96 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sat, 23 Nov 2024 03:57:12 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20=ED=82=A4=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=20=EA=B5=AC=EC=A1=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update시 localUpdate 사용 - 화살표 핸들러 실행 조건 변경 - 블록간 이동시에만 캐럿 위치 수정 - 블록 내부에서 이동할 경우 currentCaret값만 변경 --- .../editor/hooks/useMarkdownGrammer.ts | 183 +++++++++--------- 1 file changed, 96 insertions(+), 87 deletions(-) diff --git a/client/src/features/editor/hooks/useMarkdownGrammer.ts b/client/src/features/editor/hooks/useMarkdownGrammer.ts index 13604942..dcd0e1c7 100644 --- a/client/src/features/editor/hooks/useMarkdownGrammer.ts +++ b/client/src/features/editor/hooks/useMarkdownGrammer.ts @@ -11,6 +11,7 @@ import { BlockId } from "@noctaCrdt/NodeId"; import { useCallback } from "react"; import { EditorStateProps } from "@features/editor/Editor"; import { checkMarkdownPattern } from "@src/features/editor/utils/markdownPatterns"; +import { setCaretPosition } from "@src/utils/caretUtils"; interface useMarkdownGrammerProps { editorCRDT: EditorCRDT; @@ -19,7 +20,6 @@ interface useMarkdownGrammerProps { React.SetStateAction<{ clock: number; linkedList: BlockLinkedList; - currentBlock: BlockId | null; }> >; pageId: string; @@ -44,7 +44,6 @@ export const useMarkdownGrammer = ({ const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { const createNewBlock = (index: number): RemoteBlockInsertOperation => { - console.log("createNewBlock"); const operation = editorCRDT.localInsert(index, ""); // TODO: 블록 타입이 초기화가 안됨??? operation.node.type = "p"; @@ -52,14 +51,19 @@ export const useMarkdownGrammer = ({ }; const updateEditorState = (newBlockId: BlockId | null = null) => { + if ( + newBlockId !== null && + editorCRDT.currentBlock !== editorCRDT.LinkedList.getNode(newBlockId) + ) { + editorCRDT.currentBlock = editorCRDT.LinkedList.getNode(newBlockId); + } setEditorState((prev) => ({ clock: editorCRDT.clock, linkedList: editorCRDT.LinkedList, - currentBlock: newBlockId || prev.currentBlock, })); }; - const currentBlockId = editorState.currentBlock; + const currentBlockId = editorCRDT.currentBlock ? editorCRDT.currentBlock.id : null; if (!currentBlockId) return; const currentBlock = editorCRDT.LinkedList.getNode(currentBlockId); @@ -72,15 +76,16 @@ export const useMarkdownGrammer = ({ switch (e.key) { case "Enter": { e.preventDefault(); - const caretPosition = currentBlock.crdt.currentCaret; + const selection = window.getSelection(); + if (!selection) return; + const caretPosition = selection.focusOffset; const currentContent = currentBlock.crdt.read(); const afterText = currentContent.slice(caretPosition); if (!currentContent && currentBlock.type !== "p") { currentBlock.type = "p"; - // TODO: Update요청 - // remote update 보내기 - sendBlockUpdateOperation({ node: currentBlock, pageId }); + sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); + editorCRDT.currentBlock = currentBlock; updateEditorState(); break; } @@ -92,7 +97,6 @@ export const useMarkdownGrammer = ({ operation.node.crdt = new BlockCRDT(editorCRDT.client); sendBlockInsertOperation({ node: operation.node, pageId }); - updateEditorState(operation.node.id); break; } @@ -101,8 +105,6 @@ export const useMarkdownGrammer = ({ if (afterText) { // 캐럿 이후의 텍스트만 제거 for (let i = currentContent.length - 1; i >= caretPosition; i--) { - // char remote delete 보내기 - // currentBlock.crdt.localDelete(i, currentBlock.id); sendCharDeleteOperation(currentBlock.crdt.localDelete(i, currentBlock.id, pageId)); } } @@ -115,8 +117,6 @@ export const useMarkdownGrammer = ({ // 캐럿 이후의 텍스트 있으면 새 블록에 추가 if (afterText) { afterText.split("").forEach((char, i) => { - // char remote insert 보내기 - // newBlock.crdt.localInsert(i, char, newBlock.id); sendCharInsertOperation( operation.node.crdt.localInsert(i, char, operation.node.id, pageId), ); @@ -126,24 +126,22 @@ export const useMarkdownGrammer = ({ // 현재 블록이 li나 checkbox면 동일한 타입으로 생성 if (["ul", "ol", "checkbox"].includes(currentBlock.type)) { operation.node.type = currentBlock.type; - // TODO: Update요청 - // remote update 보내기 - sendBlockUpdateOperation({ node: operation.node, pageId }); + sendBlockUpdateOperation(editorCRDT.localUpdate(operation.node, pageId)); } - // !! TODO socket.update updateEditorState(operation.node.id); break; } case "Backspace": { + const selection = window.getSelection(); + const caretPosition = selection?.focusOffset || 0; const currentContent = currentBlock.crdt.read(); if (currentContent === "") { e.preventDefault(); if (currentBlock.indent > 0) { currentBlock.indent -= 1; - // TODO: Update요청 - // remote update 보내기 - sendBlockUpdateOperation({ node: currentBlock, pageId }); + sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); + editorCRDT.currentBlock = currentBlock; updateEditorState(); break; } @@ -151,9 +149,8 @@ export const useMarkdownGrammer = ({ if (currentBlock.type !== "p") { // 마지막 블록이면 기본 블록으로 변경 currentBlock.type = "p"; - // TODO: Update요청 - // remote update 보내기 - sendBlockUpdateOperation({ node: currentBlock, pageId }); + sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); + editorCRDT.currentBlock = currentBlock; updateEditorState(); break; } @@ -161,8 +158,6 @@ export const useMarkdownGrammer = ({ const prevBlock = currentIndex > 0 ? editorCRDT.LinkedList.findByIndex(currentIndex - 1) : null; if (prevBlock) { - // remote delete 보내기 - // editorCRDT.localDelete(currentIndex, undefined, pageId); sendBlockDeleteOperation(editorCRDT.localDelete(currentIndex, undefined, pageId)); prevBlock.crdt.currentCaret = prevBlock.crdt.read().length; editorCRDT.currentBlock = prevBlock; @@ -170,21 +165,18 @@ export const useMarkdownGrammer = ({ } break; } else { - const { currentCaret } = currentBlock.crdt; - if (currentCaret === 0) { + if (caretPosition === 0) { if (currentBlock.indent > 0) { currentBlock.indent -= 1; - // TODO: Update요청 - // remote update 보내기 - sendBlockUpdateOperation({ node: currentBlock, pageId }); + sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); + editorCRDT.currentBlock = currentBlock; updateEditorState(); break; } if (currentBlock.type !== "p") { currentBlock.type = "p"; - // TODO: Update요청 - // remote update 보내기 - sendBlockUpdateOperation({ node: currentBlock, pageId }); + sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); + editorCRDT.currentBlock = currentBlock; updateEditorState(); // FIX: 서윤님 피드백 반영 } else { @@ -193,8 +185,6 @@ export const useMarkdownGrammer = ({ if (prevBlock) { const prevBlockEndCaret = prevBlock.crdt.read().length; currentContent.split("").forEach((char) => { - // char remote insert 보내기 - // prevBlock.crdt.localInsert(prevBlock.crdt.read().length, char, prevBlock.id); sendCharInsertOperation( prevBlock.crdt.localInsert( prevBlock.crdt.read().length, @@ -204,12 +194,10 @@ export const useMarkdownGrammer = ({ ), ); sendCharDeleteOperation( - currentBlock.crdt.localDelete(currentCaret, currentBlock.id, pageId), + currentBlock.crdt.localDelete(caretPosition, currentBlock.id, pageId), ); }); prevBlock.crdt.currentCaret = prevBlockEndCaret; - // remote delete 보내기 - // editorCRDT.localDelete(currentIndex, undefined, pageId); sendBlockDeleteOperation(editorCRDT.localDelete(currentIndex, undefined, pageId)); updateEditorState(prevBlock.id); e.preventDefault(); @@ -228,21 +216,18 @@ export const useMarkdownGrammer = ({ // shift + tab: 들여쓰기 감소 if (currentBlock.indent > 0) { currentBlock.indent -= 1; - // TODO: Update요청 - // remote update 보내기 - sendBlockUpdateOperation({ node: currentBlock, pageId }); - updateEditorState(currentBlock.id); + sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); + editorCRDT.currentBlock = currentBlock; + updateEditorState(); } } else { // tab: 들여쓰기 증가 - console.log("tab"); const maxIndent = 3; if (currentBlock.indent < maxIndent) { currentBlock.indent += 1; - // TODO: Update요청 - // remote update 보내기 - sendBlockUpdateOperation({ node: currentBlock, pageId }); - updateEditorState(currentBlock.id); + sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); + editorCRDT.currentBlock = currentBlock; + updateEditorState(); } } } @@ -250,6 +235,9 @@ export const useMarkdownGrammer = ({ } case " ": { + // 여기 수정함 + const selection = window.getSelection(); + if (!selection) return; const currentContent = currentBlock.crdt.read(); const markdownElement = checkMarkdownPattern(currentContent); if (markdownElement && currentBlock.type === "p") { @@ -258,17 +246,13 @@ export const useMarkdownGrammer = ({ currentBlock.type = markdownElement.type; let deleteCount = 0; while (deleteCount < markdownElement.length) { - // char remote delete 보내기 - // currentBlock.crdt.localDelete(0, currentBlock.id); sendCharDeleteOperation(currentBlock.crdt.localDelete(0, currentBlock.id, pageId)); deleteCount += 1; } - // TODO: Update요청 - // remote update 보내기 - sendBlockUpdateOperation({ node: currentBlock, pageId }); - // !!TODO emit 송신 + sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); currentBlock.crdt.currentCaret = 0; - updateEditorState(currentBlock.id); + editorCRDT.currentBlock = currentBlock; + updateEditorState(); } break; @@ -276,57 +260,82 @@ export const useMarkdownGrammer = ({ case "ArrowUp": case "ArrowDown": { - e.preventDefault(); - const blocks = editorCRDT.LinkedList.spread(); - // 이전/다음 블록 존재 여부 확인 const hasPrevBlock = currentIndex > 0; - const hasNextBlock = currentIndex < blocks.length - 1; - // 방향키에 따라 이동 가능 여부 확인 - if (e.key === "ArrowUp" && !hasPrevBlock) return; - if (e.key === "ArrowDown" && !hasNextBlock) return; + const hasNextBlock = currentIndex < editorCRDT.LinkedList.spread().length - 1; + if (e.key === "ArrowUp" && !hasPrevBlock) { + e.preventDefault(); + return; + } + if (e.key === "ArrowDown" && !hasNextBlock) { + e.preventDefault(); + return; + } - const targetIndex = e.key === "ArrowUp" ? currentIndex - 1 : currentIndex + 1; + const selection = window.getSelection(); + const caretPosition = selection?.focusOffset || 0; - const targetBlock = blocks[targetIndex]; - targetBlock.crdt.currentCaret = Math.min( - currentBlock.crdt.currentCaret, - targetBlock.crdt.read().length, - ); - updateEditorState(targetBlock.id); + // 이동할 블록 결정 + const targetIndex = e.key === "ArrowUp" ? currentIndex - 1 : currentIndex + 1; + const targetBlock = editorCRDT.LinkedList.findByIndex(targetIndex); + if (!targetBlock) return; + e.preventDefault(); + targetBlock.crdt.currentCaret = Math.min(caretPosition, targetBlock.crdt.read().length); + editorCRDT.currentBlock = targetBlock; + setCaretPosition({ + blockId: targetBlock.id, + linkedList: editorCRDT.LinkedList, + position: Math.min(caretPosition, targetBlock.crdt.read().length), + }); break; } - // TODO: ArrowLeft, ArrowRight 블록 이동시 캐럿 이상하게 움직이는 것 수정 case "ArrowLeft": case "ArrowRight": { - const { currentCaret } = currentBlock.crdt; + const selection = window.getSelection(); + const caretPosition = selection?.focusOffset || 0; const textLength = currentBlock.crdt.read().length; - if (e.key === "ArrowLeft" && currentCaret > 0) { - // currentBlock.crdt.currentCaret = currentCaret - 1; - currentBlock.crdt.currentCaret -= 1; - break; - } - if (e.key === "ArrowRight" && currentCaret < textLength) { - // currentBlock.crdt.currentCaret = currentCaret + 1; - currentBlock.crdt.currentCaret += 1; - break; - } - if (e.key === "ArrowLeft" && currentCaret === 0 && currentIndex > 0) { + + // 왼쪽 끝에서 이전 블록으로 + if (e.key === "ArrowLeft" && caretPosition === 0 && currentIndex > 0) { + e.preventDefault(); // 기본 동작 방지 const prevBlock = editorCRDT.LinkedList.findByIndex(currentIndex - 1); if (prevBlock) { prevBlock.crdt.currentCaret = prevBlock.crdt.read().length; - updateEditorState(prevBlock.id); + editorCRDT.currentBlock = prevBlock; + setCaretPosition({ + blockId: prevBlock.id, + linkedList: editorCRDT.LinkedList, + position: prevBlock.crdt.read().length, + }); } break; - } - if (e.key === "ArrowRight" && currentCaret === textLength) { - // TODO: 다음 블록 없을 때 처리 crdt에 추가 - const nextBlock = editorCRDT.LinkedList.findByIndex(currentIndex + 1); + // 오른쪽 끝에서 다음 블록으로 + } else if ( + e.key === "ArrowRight" && + caretPosition === textLength && + currentIndex < editorCRDT.LinkedList.spread().length - 1 + ) { + e.preventDefault(); // 기본 동작 방지 + const nextBlock = editorState.linkedList.findByIndex(currentIndex + 1); if (nextBlock) { nextBlock.crdt.currentCaret = 0; - updateEditorState(nextBlock.id); + editorCRDT.currentBlock = nextBlock; + setCaretPosition({ + blockId: nextBlock.id, + linkedList: editorCRDT.LinkedList, + position: 0, + }); } break; + // 블록 내에서 이동하는 경우 + } else { + if (e.key === "ArrowLeft") { + currentBlock.crdt.currentCaret -= 1; + } else { + currentBlock.crdt.currentCaret += 1; + } } + + break; } } }, From 16aa9c5f3c79be5adc22e8ed35470bca9158a063 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sat, 23 Nov 2024 04:30:15 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20editorState=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD=EB=90=9C=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/editor/hooks/useBlockOption.ts | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/client/src/features/editor/hooks/useBlockOption.ts b/client/src/features/editor/hooks/useBlockOption.ts index 595c65e6..6b023bd5 100644 --- a/client/src/features/editor/hooks/useBlockOption.ts +++ b/client/src/features/editor/hooks/useBlockOption.ts @@ -18,7 +18,6 @@ interface useBlockOptionSelectProps { React.SetStateAction<{ clock: number; linkedList: BlockLinkedList; - currentBlock: BlockId | null; }> >; pageId: string; @@ -43,6 +42,7 @@ export const useBlockOptionSelect = ({ if (!block) return; block.type = type; + editorCRDT.currentBlock = block; editorCRDT.remoteUpdate(block, pageId); sendBlockUpdateOperation({ @@ -50,11 +50,10 @@ export const useBlockOptionSelect = ({ pageId, }); - setEditorState((prev) => ({ + setEditorState({ clock: editorCRDT.clock, linkedList: editorCRDT.LinkedList, - currentBlock: blockId || prev.currentBlock, - })); + }); }; const handleAnimationSelect = (blockId: BlockId, animation: AnimationType) => { @@ -62,6 +61,7 @@ export const useBlockOptionSelect = ({ if (!block) return; block.animation = animation; + editorCRDT.currentBlock = block; editorCRDT.remoteUpdate(block, pageId); sendBlockUpdateOperation({ @@ -69,11 +69,10 @@ export const useBlockOptionSelect = ({ pageId, }); - setEditorState((prev) => ({ + setEditorState({ clock: editorCRDT.clock, linkedList: editorCRDT.LinkedList, - currentBlock: blockId || prev.currentBlock, - })); + }); }; const handleCopySelect = (blockId: BlockId) => { @@ -112,11 +111,11 @@ export const useBlockOptionSelect = ({ pageId, }); - setEditorState((prev) => ({ + editorCRDT.currentBlock = operation.node; + setEditorState({ clock: editorCRDT.clock, linkedList: editorCRDT.LinkedList, - currentBlock: operation.node.id || prev.currentBlock, - })); + }); }; const handleDeleteSelect = (blockId: BlockId) => { @@ -125,11 +124,18 @@ export const useBlockOptionSelect = ({ ); sendBlockDeleteOperation(editorCRDT.localDelete(currentIndex, undefined, pageId)); - setEditorState((prev) => ({ + // 삭제할 블록이 현재 활성화된 블록인 경우 + if (editorCRDT.currentBlock?.id.equals(blockId)) { + // 다음 블록이나 이전 블록으로 currentBlock 설정 + const nextBlock = editorCRDT.LinkedList.findByIndex(currentIndex + 1); + const prevBlock = editorCRDT.LinkedList.findByIndex(currentIndex - 1); + editorCRDT.currentBlock = nextBlock || prevBlock || null; + } + + setEditorState({ clock: editorCRDT.clock, linkedList: editorCRDT.LinkedList, - currentBlock: prev.currentBlock, - })); + }); }; return { From f5c922120b06bd282e52e9b20941369f7903986d Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sat, 23 Nov 2024 04:31:52 +0900 Subject: [PATCH 08/10] =?UTF-8?q?feat:=20=EB=9D=BC=EB=B2=A8=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/constants/option.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/constants/option.ts b/client/src/constants/option.ts index a31fe835..75193a17 100644 --- a/client/src/constants/option.ts +++ b/client/src/constants/option.ts @@ -5,14 +5,14 @@ export const OPTION_CATEGORIES = { id: "type", label: "전환", options: [ - { id: "p", label: "p" }, - { id: "h1", label: "h1" }, - { id: "h2", label: "h2" }, - { id: "h3", label: "h3" }, - { id: "ul", label: "ul" }, - { id: "ol", label: "ol" }, - { id: "checkbox", label: "checkbox" }, - { id: "blockquote", label: "blockquote" }, + { id: "p", label: "기본" }, + { id: "h1", label: "제목 1" }, + { id: "h2", label: "제목 2" }, + { id: "h3", label: "제목 3" }, + { id: "ul", label: "리스트" }, + { id: "ol", label: "순서 리스트" }, + { id: "checkbox", label: "체크박스" }, + { id: "blockquote", label: "인용문" }, ] as { id: ElementType; label: string }[], }, ANIMATION: { From 400be0a53fe2fb0c49634a4d0dbe89096172812e Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sat, 23 Nov 2024 04:45:24 +0900 Subject: [PATCH 09/10] =?UTF-8?q?feat:=20=EB=B8=94=EB=A1=9D=20=EC=B2=AB=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B2=84=ED=8A=BC=20=EB=B8=94=EB=A1=9D=20?= =?UTF-8?q?=EC=97=86=EC=9D=84=EB=95=8C=EB=A7=8C=20=EB=B3=B4=EC=9D=B4?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/editor/Editor.style.ts | 13 ++++++ client/src/features/editor/Editor.tsx | 49 ++++++++++++---------- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/client/src/features/editor/Editor.style.ts b/client/src/features/editor/Editor.style.ts index 7292d919..54f6861e 100644 --- a/client/src/features/editor/Editor.style.ts +++ b/client/src/features/editor/Editor.style.ts @@ -50,3 +50,16 @@ export const checkbox = css({ backgroundColor: "blue.500", }, }); + +export const addNewBlockButton = css({ + display: "flex", + gap: "spacing.sm", + borderRadius: "4px", + padding: "spacing.sm", + color: "gray.900", + opacity: 0.8, + cursor: "pointer", + "&:hover": { + opacity: 1, + }, +}); diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index 9ff7274d..56bec69a 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -11,7 +11,12 @@ import { import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from "react"; import { useSocketStore } from "@src/stores/useSocketStore.ts"; import { setCaretPosition } from "@src/utils/caretUtils.ts"; -import { editorContainer, editorTitleContainer, editorTitle } from "./Editor.style"; +import { + editorContainer, + editorTitleContainer, + editorTitle, + addNewBlockButton, +} from "./Editor.style"; import { Block } from "./components/block/Block.tsx"; import { useBlockDragAndDrop } from "./hooks/useBlockDragAndDrop"; import { useBlockOptionSelect } from "./hooks/useBlockOption.ts"; @@ -148,10 +153,10 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr }); }); } - setEditorState((prev) => ({ + setEditorState({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, - })); + }); }, [sendCharInsertOperation, sendCharDeleteOperation], ); @@ -176,20 +181,20 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr console.log(operation, "block : 입력 확인합니다이"); if (!editorCRDT.current) return; editorCRDT.current.remoteInsert(operation); - setEditorState((prev) => ({ + setEditorState({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, - })); + }); }, onRemoteBlockDelete: (operation) => { console.log(operation, "block : 삭제 확인합니다이"); if (!editorCRDT.current) return; editorCRDT.current.remoteDelete(operation); - setEditorState((prev) => ({ + setEditorState({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, - })); + }); }, onRemoteCharInsert: (operation) => { @@ -198,10 +203,10 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr const targetBlock = editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; targetBlock.crdt.remoteInsert(operation); - setEditorState((prev) => ({ + setEditorState({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, - })); + }); }, onRemoteCharDelete: (operation) => { @@ -210,10 +215,10 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr const targetBlock = editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; targetBlock.crdt.remoteDelete(operation); - setEditorState((prev) => ({ + setEditorState({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, - })); + }); }, onRemoteBlockUpdate: (operation) => { @@ -222,20 +227,20 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr // ?? console.log("타입", operation.node); editorCRDT.current.remoteUpdate(operation.node, operation.pageId); - setEditorState((prev) => ({ + setEditorState({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, - })); + }); }, onRemoteBlockReorder: (operation) => { console.log(operation, "block : 재정렬 확인합니다이"); if (!editorCRDT.current) return; editorCRDT.current.remoteReorder(operation); - setEditorState((prev) => ({ + setEditorState({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, - })); + }); }, onRemoteCursor: (position) => { @@ -249,18 +254,16 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr }; }, []); - const tempBlock = () => { + const addNewBlock = () => { const index = editorCRDT.current.LinkedList.spread().length; // 로컬 삽입을 수행하고 연산 객체를 반환받음 const operation = editorCRDT.current.localInsert(index, ""); sendBlockInsertOperation({ node: operation.node, pageId }); - console.log("operation clock", operation.node); - setEditorState(() => ({ + setEditorState({ clock: operation.node.id.clock, linkedList: editorCRDT.current.LinkedList, - currentBlock: operation.node.id, - })); + }); }; return ( @@ -296,7 +299,11 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr ))} -
임시
+ {editorState.linkedList.spread().length === 0 && ( +
+ 클릭해서 새로운 블록을 추가하세요 +
+ )} ); From 8f493b440b575e92956b3121d4f08f4b415a8bd0 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sat, 23 Nov 2024 04:48:08 +0900 Subject: [PATCH 10/10] =?UTF-8?q?chore:=20lint=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/editor/components/block/Block.tsx | 2 +- client/src/features/editor/hooks/useBlockDragAndDrop.ts | 1 - client/src/features/editor/hooks/useMarkdownGrammer.ts | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index 6b4f9f97..e9fdfb9b 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -102,7 +102,7 @@ export const Block: React.FC = memo( ref={blockRef} onKeyDown={onKeyDown} onInput={handleInput} - onClick={(e) => onClick(block.id)} + onClick={() => onClick(block.id)} contentEditable suppressContentEditableWarning className={textContainerStyle({ diff --git a/client/src/features/editor/hooks/useBlockDragAndDrop.ts b/client/src/features/editor/hooks/useBlockDragAndDrop.ts index 61c35eb9..ae880a5b 100644 --- a/client/src/features/editor/hooks/useBlockDragAndDrop.ts +++ b/client/src/features/editor/hooks/useBlockDragAndDrop.ts @@ -91,7 +91,6 @@ export const useBlockDragAndDrop = ({ setEditorState({ clock: editorCRDT.clock, linkedList: editorCRDT.LinkedList, - currentBlock: editorState.currentBlock, }); } catch (error) { console.error("Failed to reorder blocks:", error); diff --git a/client/src/features/editor/hooks/useMarkdownGrammer.ts b/client/src/features/editor/hooks/useMarkdownGrammer.ts index dcd0e1c7..3a1308f7 100644 --- a/client/src/features/editor/hooks/useMarkdownGrammer.ts +++ b/client/src/features/editor/hooks/useMarkdownGrammer.ts @@ -57,10 +57,10 @@ export const useMarkdownGrammer = ({ ) { editorCRDT.currentBlock = editorCRDT.LinkedList.getNode(newBlockId); } - setEditorState((prev) => ({ + setEditorState({ clock: editorCRDT.clock, linkedList: editorCRDT.LinkedList, - })); + }); }; const currentBlockId = editorCRDT.currentBlock ? editorCRDT.currentBlock.id : null;