diff --git a/@noctaCrdt/LinkedList.ts b/@noctaCrdt/LinkedList.ts index de84c19a..759c07a0 100644 --- a/@noctaCrdt/LinkedList.ts +++ b/@noctaCrdt/LinkedList.ts @@ -300,6 +300,8 @@ export class BlockLinkedList extends LinkedList { if (targetNode.prev) { const prevNode = this.getNode(targetNode.prev); if (prevNode) prevNode.next = targetNode.next; + } else { + this.head = targetNode.next; } if (targetNode.next) { diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index 6951cbf2..962a0e14 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -1,4 +1,4 @@ -import { DndContext } from "@dnd-kit/core"; +import { DndContext, DragEndEvent } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { EditorCRDT } from "@noctaCrdt/Crdt"; import { BlockLinkedList } from "@noctaCrdt/LinkedList"; @@ -48,6 +48,7 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData const [displayTitle, setDisplayTitle] = useState( pageTitle === "새로운 페이지" || pageTitle === "" ? "" : pageTitle, ); + const [dragBlockList, setDragBlockList] = useState([]); const editorCRDTInstance = useMemo(() => { let newEditorCRDT; @@ -80,7 +81,7 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData addNewBlock, } = useEditorOperation({ editorCRDT, pageId, setEditorState }); - const { sensors, handleDragEnd } = useBlockDragAndDrop({ + const { sensors, handleDragEnd, handleDragStart } = useBlockDragAndDrop({ editorCRDT: editorCRDT.current, editorState, setEditorState, @@ -272,7 +273,15 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData className={editorTitle} />
- + { + handleDragEnd(event, dragBlockList, () => setDragBlockList([])); + }} + onDragStart={(event) => { + handleDragStart(event, setDragBlockList); + }} + sensors={sensors} + > ))} diff --git a/client/src/features/editor/components/IconBlock/IconBlock.style.ts b/client/src/features/editor/components/IconBlock/IconBlock.style.ts index 45076f63..be2c8720 100644 --- a/client/src/features/editor/components/IconBlock/IconBlock.style.ts +++ b/client/src/features/editor/components/IconBlock/IconBlock.style.ts @@ -20,7 +20,7 @@ export const iconStyle = cva({ variants: { type: { ul: { - fontSize: "20px", // bullet point size + fontSize: "6px", // bullet point size }, ol: { paddingRight: "4px", diff --git a/client/src/features/editor/components/IconBlock/IconBlock.tsx b/client/src/features/editor/components/IconBlock/IconBlock.tsx index 5c369560..70c466db 100644 --- a/client/src/features/editor/components/IconBlock/IconBlock.tsx +++ b/client/src/features/editor/components/IconBlock/IconBlock.tsx @@ -4,13 +4,20 @@ import { iconContainerStyle, iconStyle } from "./IconBlock.style"; interface IconBlockProps { type: ElementType; index: number | undefined; + indent?: number; } -export const IconBlock = ({ type, index = 1 }: IconBlockProps) => { +export const IconBlock = ({ type, index = 1, indent = 0 }: IconBlockProps) => { const getIcon = () => { switch (type) { case "ul": - return ; + return ( + + {indent === 0 && "●"} + {indent === 1 && "○"} + {indent === 2 && "■"} + + ); case "ol": return {`${index}.`}; case "checkbox": diff --git a/client/src/features/editor/components/block/Block.style.ts b/client/src/features/editor/components/block/Block.style.ts index bf73bd7f..89e9dc3e 100644 --- a/client/src/features/editor/components/block/Block.style.ts +++ b/client/src/features/editor/components/block/Block.style.ts @@ -142,3 +142,30 @@ export const textContainerStyle = cva({ type: "p", }, }); + +export const dropIndicatorStyle = cva({ + base: { + zIndex: "10", + position: "absolute", + height: "2px", + }, + variants: { + indent: { + first: { + left: "0", + width: "100%", + backgroundColor: "#ADADFF", + }, + second: { + left: "10px", + width: "calc(100% - 10px)", + backgroundColor: "#9B9BFF ", + }, + third: { + left: "20px", + width: "calc(100% - 20px)", + backgroundColor: "#8989FF", + }, + }, + }, +}); diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index c3bea61d..4e165c18 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -1,5 +1,4 @@ import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; import { AnimationType, ElementType, @@ -20,11 +19,17 @@ import { MenuBlock } from "../MenuBlock/MenuBlock"; import { TextOptionModal } from "../TextOptionModal/TextOptionModal"; import { TypeOptionModal } from "../TypeOptionModal/TypeOptionModal"; import { blockAnimation } from "./Block.animation"; -import { textContainerStyle, blockContainerStyle, contentWrapperStyle } from "./Block.style"; +import { + textContainerStyle, + blockContainerStyle, + contentWrapperStyle, + dropIndicatorStyle, +} from "./Block.style"; interface BlockProps { id: string; block: CRDTBlock; + dragBlockList: string[]; isActive: boolean; onInput: (e: React.FormEvent, block: CRDTBlock) => void; onCompositionEnd: (e: React.CompositionEvent, block: CRDTBlock) => void; @@ -64,6 +69,7 @@ export const Block: React.FC = memo( ({ id, block, + dragBlockList, isActive, onInput, onCompositionEnd, @@ -83,13 +89,23 @@ export const Block: React.FC = memo( const { isOpen, openModal, closeModal } = useModal(); const [selectedNodes, setSelectedNodes] = useState | null>(null); const { isAnimationStart } = useBlockAnimation(blockRef); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id, - data: { - type: "block", - block, - }, - }); + const { attributes, listeners, setNodeRef, isDragging, isOver, activeIndex, overIndex, data } = + useSortable({ + id, + data: { + id, + type: "block", + block, + }, + }); + + // 현재 드래그 중인 부모 블록의 indent 확인 + const isChildOfDragging = dragBlockList.some((item) => item === data.id); + + // NOTE 드롭 인디케이터 위치 계산 + // 현재 over 중인 블럭 위치 + 위/아래로 모두 인디케이터 표시 + 부모요소는 자식요소 내부로는 이동하지 못함 + const showTopIndicator = isOver && !isChildOfDragging && activeIndex >= overIndex; + const showBottomIndicator = isOver && !isChildOfDragging && activeIndex < overIndex; const [slashModalOpen, setSlashModalOpen] = useState(false); const [slashModalPosition, setSlashModalPosition] = useState({ top: 0, left: 0 }); @@ -214,6 +230,14 @@ export const Block: React.FC = memo( } }; + const Indicator = () => ( +
+ ); + useEffect(() => { if (blockRef.current) { setInnerHTML({ element: blockRef.current, block }); @@ -223,66 +247,66 @@ export const Block: React.FC = memo( return ( // TODO: eslint 규칙을 수정해야 할까? // TODO: ol일때 index 순서 처리 - +
+ {showTopIndicator && } - + + +
onKeyDown(e, blockRef.current, block)} + onInput={handleInput} + onClick={(e) => onClick(block.id, e)} + onCopy={(e) => onCopy(e, blockRef.current, block)} + onPaste={(e) => onPaste(e, blockRef.current, block)} + onMouseUp={handleMouseUp} + onCompositionEnd={(e) => onCompositionEnd(e, block)} + contentEditable={block.type !== "hr"} + spellCheck={false} + suppressContentEditableWarning + className={textContainerStyle({ + type: block.type, + })} + /> + + handleStyleSelect("bold")} + onItalicSelect={() => handleStyleSelect("italic")} + onUnderlineSelect={() => handleStyleSelect("underline")} + onStrikeSelect={() => handleStyleSelect("strikethrough")} + onTextColorSelect={handleTextColorSelect} + onTextBackgroundColorSelect={handleTextBackgroundColorSelect} /> - -
onKeyDown(e, blockRef.current, block)} - onInput={handleInput} - onClick={(e) => onClick(block.id, e)} - onCopy={(e) => onCopy(e, blockRef.current, block)} - onPaste={(e) => onPaste(e, blockRef.current, block)} - onMouseUp={handleMouseUp} - onCompositionEnd={(e) => onCompositionEnd(e, block)} - contentEditable={block.type !== "hr"} - spellCheck={false} - suppressContentEditableWarning - className={textContainerStyle({ - type: block.type, - })} + setSlashModalOpen(false)} + onTypeSelect={(type) => handleTypeSelect(type)} + position={slashModalPosition} /> - handleStyleSelect("bold")} - onItalicSelect={() => handleStyleSelect("italic")} - onUnderlineSelect={() => handleStyleSelect("underline")} - onStrikeSelect={() => handleStyleSelect("strikethrough")} - onTextColorSelect={handleTextColorSelect} - onTextBackgroundColorSelect={handleTextBackgroundColorSelect} - /> - setSlashModalOpen(false)} - onTypeSelect={(type) => handleTypeSelect(type)} - position={slashModalPosition} - /> - + {showBottomIndicator && } +
); }, ); diff --git a/client/src/features/editor/hooks/useBlockDragAndDrop.ts b/client/src/features/editor/hooks/useBlockDragAndDrop.ts index ae880a5b..db65a6c7 100644 --- a/client/src/features/editor/hooks/useBlockDragAndDrop.ts +++ b/client/src/features/editor/hooks/useBlockDragAndDrop.ts @@ -1,8 +1,12 @@ -// hooks/useBlockDragAndDrop.ts -import { DragEndEvent, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; +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"; interface UseBlockDragAndDropProps { editorCRDT: EditorCRDT; @@ -25,12 +29,98 @@ export const useBlockDragAndDrop = ({ }), ); - const { sendBlockReorderOperation } = useSocketStore(); + const { sendBlockReorderOperation, sendBlockUpdateOperation } = useSocketStore(); + + const getBlocksToMove = (nodes: Block[], parentIndex: number, parentIndent: number): Block[] => { + const blocksToMove = []; + let i = parentIndex + 1; + + // 자식 블록들 찾기 + while (i < nodes.length && nodes[i].indent > parentIndent) { + blocksToMove.push(nodes[i]); + i += 1; + } + + return blocksToMove; + }; + + const reorderBlocksWithChildren = ( + nodes: Block[], + targetNode: Block, + beforeNode: Block | null, + afterNode: Block | null, + ) => { + const operations = []; + const targetIndex = nodes.indexOf(targetNode); + const childBlocks = getBlocksToMove(nodes, targetIndex, targetNode.indent); + + // 이동할 위치의 부모 블록 indent 찾기 + let newIndent = 0; + if (beforeNode) { + // 앞 블록이 있는 경우, 그 블록의 indent를 기준으로 + newIndent = beforeNode.indent; + } else if (afterNode) { + // 뒤 블록이 있는 경우, 그 블록의 indent를 기준으로 + newIndent = afterNode.indent; + } + + // indent 변화량 계산 -> 추후 자식 블록들에 indentDiff만큼 적용 + const indentDiff = newIndent - targetNode.indent; + // 타겟 블록 업데이트 + targetNode.indent = newIndent; + + // 타겟 블록의 reorder 연산 처리 + const targetReorderOp = editorCRDT.localReorder({ + targetId: targetNode.id, + beforeId: beforeNode?.id || null, + afterId: afterNode?.id || null, + pageId, + }); + operations.push({ type: "reorder", operation: targetReorderOp }); + + // Update 연산 (indent 갱신) + const targetUpdateOp = editorCRDT.localUpdate(targetNode, pageId); + operations.push({ type: "update", operation: targetUpdateOp }); + + // 자식 블록들 처리 + let prevBlock = targetNode; + childBlocks.forEach((childBlock, index) => { + const childNewIndent = Math.max(0, childBlock.indent + indentDiff); + childBlock.indent = childNewIndent; + + // 마지막일 경우 after Id를 afterNode 로 설정 + const childReorderOp = editorCRDT.localReorder({ + targetId: childBlock.id, + beforeId: prevBlock.id, + afterId: afterNode && index === childBlocks.length - 1 ? afterNode?.id : null, + pageId, + }); + operations.push({ type: "reorder", operation: childReorderOp }); + + const childUpdateOp = editorCRDT.localUpdate(childBlock, pageId); + operations.push({ type: "update", operation: childUpdateOp }); + + prevBlock = childBlock; + }); + + return operations; + }; + + const handleDragEnd = ( + event: DragEndEvent, + dragBlockList: string[], + initDraggingBlock: () => void, + ) => { + // 커서 다시 원래대로 + document.body.style.cursor = "auto"; + initDraggingBlock(); - const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; + if (!over) return; - if (!over || active.id === over.id) return; + // 지금 놓으려는 블록(over)이 드래깅 중인 블록이거나, 드래깅 중인 블록의 자식 블록이면 무시 + const disableDrag = dragBlockList.some((item) => item === over.data.current?.id); + if (disableDrag) return; try { const nodes = editorState.linkedList.spread(); @@ -53,13 +143,11 @@ export const useBlockDragAndDrop = ({ (block) => block.id.client === overInfo.client && block.id.clock === overInfo.clock, ); - if (!targetNode || !overNode) { - throw new Error("Unable to find target or destination node"); - } + if (!targetNode || !overNode) return; + // 드래그 방향에 따라 beforeNode와 afterNode 결정 const targetIndex = nodes.indexOf(targetNode); const overIndex = nodes.indexOf(overNode); - // 드래그 방향 결정 const isMoveDown = targetIndex < overIndex; @@ -76,18 +164,18 @@ export const useBlockDragAndDrop = ({ beforeNode = overIndex > 0 ? nodes[overIndex - 1] : null; afterNode = overNode; } - // EditorCRDT의 현재 상태로 작업 - const operation = editorCRDT.localReorder({ - targetId: targetNode.id, - beforeId: beforeNode?.id || null, - afterId: afterNode?.id || null, - pageId, + const operations = reorderBlocksWithChildren(nodes, targetNode, beforeNode, afterNode); + + // 각 operation type에 따라 함수 호출 (reorder + update(indent 갱신)) + operations.forEach((op) => { + if (op.type === "reorder") { + sendBlockReorderOperation(op.operation as RemoteBlockReorderOperation); + } else if (op.type === "update") { + sendBlockUpdateOperation(op.operation as RemoteBlockUpdateOperation); + } }); - sendBlockReorderOperation(operation); - - // EditorState 업데이트 setEditorState({ clock: editorCRDT.clock, linkedList: editorCRDT.LinkedList, @@ -97,8 +185,45 @@ export const useBlockDragAndDrop = ({ } }; + const handleDragStart = ( + event: DragStartEvent, + setDragBlockList: React.Dispatch>, + ) => { + document.body.style.cursor = "grabbing"; + const { active } = event; + const parentId = active.data.current?.id; + const parentIndent = active.data.current?.block.indent; + + if (!parentId) return; + + const findChildBlocks = (parentId: string) => { + const blocks = editorState.linkedList.spread(); + const parentIndex = blocks.findIndex( + (block) => `${block.id.client}-${block.id.clock}` === parentId, + ); + + if (parentIndex === -1) return []; + + const childBlockIds = []; + + for (let i = parentIndex + 1; i < blocks.length; i++) { + if (blocks[i].indent > parentIndent) { + childBlockIds.push(`${blocks[i].id.client}-${blocks[i].id.clock}`); + } else { + break; + } + } + + return childBlockIds; + }; + + const childBlockIds = findChildBlocks(parentId); + setDragBlockList([parentId, ...childBlockIds]); + }; + return { sensors, handleDragEnd, + handleDragStart, }; }; diff --git a/client/src/features/editor/hooks/useEditorOperation.ts b/client/src/features/editor/hooks/useEditorOperation.ts index 2917c6c5..84656b07 100644 --- a/client/src/features/editor/hooks/useEditorOperation.ts +++ b/client/src/features/editor/hooks/useEditorOperation.ts @@ -47,7 +47,7 @@ export const useEditorOperation = ({ const prevBlock = editorCRDT.current.LinkedList.nodeMap[JSON.stringify(targetBlock.prev)]; const nextBlock = editorCRDT.current.LinkedList.nodeMap[JSON.stringify(targetBlock.next)]; editorCRDT.current.remoteDelete(operation); - if (prevBlock.type === "ol" && nextBlock.type === "ol") { + if (prevBlock && prevBlock.type === "ol" && nextBlock.type === "ol") { editorCRDT.current.LinkedList.updateAllOrderedListIndices(); } setEditorState({ @@ -93,7 +93,7 @@ export const useEditorOperation = ({ if (operation.pageId !== pageId) return; const prevBlock = editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.node.prev)]; editorCRDT.current.remoteUpdate(operation.node, operation.pageId); - if (prevBlock.type === "ol") { + if (prevBlock && prevBlock.type === "ol") { editorCRDT.current.LinkedList.updateAllOrderedListIndices(); } setEditorState({ diff --git a/client/src/features/editor/hooks/useMarkdownGrammer.ts b/client/src/features/editor/hooks/useMarkdownGrammer.ts index 5ad1234f..e2be299a 100644 --- a/client/src/features/editor/hooks/useMarkdownGrammer.ts +++ b/client/src/features/editor/hooks/useMarkdownGrammer.ts @@ -64,6 +64,44 @@ export const useMarkdownGrammer = ({ return editorCRDT.LinkedList.findByIndex(index); }; + const decreaseIndent = (currentBlock: Block) => { + if (currentBlock.indent === 0) return; + + const currentIndex = editorCRDT.LinkedList.spread().findIndex((block) => + block.id.equals(currentBlock.id), + ); + + // 현재 블록의 indent 감소 + const wasOrderedList = currentBlock.type === "ol"; + const originalIndent = currentBlock.indent; + const newIndent = originalIndent - 1; + currentBlock.indent = newIndent; + sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); + + // 자식 블록들 찾기 및 업데이트 + const blocks = editorCRDT.LinkedList.spread(); + let i = currentIndex + 1; + + // 현재 블록의 원래 indent보다 큰 블록들만 처리 (자식 블록들만) + while (i < blocks.length && blocks[i].indent > originalIndent) { + const childBlock = blocks[i]; + + // 자식 블록의 indent도 1 감소 + childBlock.indent = Math.max(0, childBlock.indent - 1); + sendBlockUpdateOperation(editorCRDT.localUpdate(childBlock, pageId)); + + i += 1; + } + + // ordered list인 경우 인덱스 업데이트 + if (wasOrderedList) { + editorCRDT.LinkedList.updateAllOrderedListIndices(); + } + + editorCRDT.currentBlock = currentBlock; + updateEditorState(); + }; + const currentBlockId = editorCRDT.currentBlock ? editorCRDT.currentBlock.id : null; if (!currentBlockId) return; @@ -166,14 +204,7 @@ export const useMarkdownGrammer = ({ if (currentContent === "") { e.preventDefault(); if (currentBlock.indent > 0) { - const wasOrderedList = currentBlock.type === "ol"; - currentBlock.indent -= 1; - sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); - editorCRDT.currentBlock = currentBlock; - if (wasOrderedList) { - editorCRDT.LinkedList.updateAllOrderedListIndices(); - } - updateEditorState(); + decreaseIndent(currentBlock); break; } @@ -252,10 +283,7 @@ export const useMarkdownGrammer = ({ const currentCaretPosition = currentBlock.crdt.currentCaret; if (currentCaretPosition === 0) { if (currentBlock.indent > 0) { - currentBlock.indent -= 1; - sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); - editorCRDT.currentBlock = currentBlock; - updateEditorState(); + decreaseIndent(currentBlock); break; } if (currentBlock.type !== "p") { @@ -332,18 +360,20 @@ export const useMarkdownGrammer = ({ if (e.shiftKey) { // shift + tab: 들여쓰기 감소 if (currentBlock.indent > 0) { - const isOrderedList = currentBlock.type === "ol"; - currentBlock.indent -= 1; - sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); - editorCRDT.currentBlock = currentBlock; - if (isOrderedList) { - editorCRDT.LinkedList.updateAllOrderedListIndices(); - } - updateEditorState(); + decreaseIndent(currentBlock); } } else { - // tab: 들여쓰기 증가 - const maxIndent = 3; + if (!currentBlock.prev) return; + + const parentIndent = + editorCRDT.LinkedList.nodeMap[JSON.stringify(currentBlock.prev)].indent; + + const maxIndent = Math.min( + parentIndent + 1, // 부모 indent + 1 + 2, // 들여쓰기 최대 indent + ); + + // 현재 indent가 허용된 최대값보다 작을 때만 들여쓰기 증가 if (currentBlock.indent < maxIndent) { const isOrderedList = currentBlock.type === "ol"; currentBlock.indent += 1;