diff --git a/@noctaCrdt/Interfaces.ts b/@noctaCrdt/Interfaces.ts index b265c3f0..56c55f5e 100644 --- a/@noctaCrdt/Interfaces.ts +++ b/@noctaCrdt/Interfaces.ts @@ -80,6 +80,7 @@ export interface RemotePageDeleteOperation { type: "pageDelete"; clientId: number; workspaceId: string; + pageTitle: string; pageId: string; } 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/components/Toast/Toast.style.ts b/client/src/components/Toast/Toast.style.ts new file mode 100644 index 00000000..02e0caaf --- /dev/null +++ b/client/src/components/Toast/Toast.style.ts @@ -0,0 +1,33 @@ +import { css } from "@styled-system/css"; + +export const ToastWrapper = css({ + display: "flex", + // position: "relative", // progress bar를 위한 position 설정 + gap: "2", + alignItems: "center", + borderRadius: "lg", + width: "fit-content", + paddingBlock: "2", + paddingInline: "4", + color: "white", + backgroundColor: "gray.700", + boxShadow: "lg", + // overflow: "hidden", // progress bar가 넘치지 않도록 + transition: "all", + transitionDuration: "300ms", +}); + +export const CloseItemBox = css({ + width: "15px", + height: "15px", +}); + +export const ToastProgress = css({ + position: "absolute", + left: "0px", + bottom: "0px", + borderRadius: "lg", + width: "100%", + height: "6px", + backgroundColor: "blue", +}); diff --git a/client/src/components/Toast/Toast.tsx b/client/src/components/Toast/Toast.tsx new file mode 100644 index 00000000..3439f333 --- /dev/null +++ b/client/src/components/Toast/Toast.tsx @@ -0,0 +1,59 @@ +import { motion } from "framer-motion"; +import { useState, useEffect } from "react"; +import CloseIcon from "@assets/icons/close.svg?react"; +import { ToastWrapper, CloseItemBox, ToastProgress } from "./Toast.style"; + +interface ToastProps { + message: string; + duration?: number; + onClose: () => void; +} + +export const Toast = ({ message, duration = 3000, onClose }: ToastProps) => { + const [isVisible, setIsVisible] = useState(true); + const [isClosing, setIsClosing] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => { + setIsClosing(true); + setTimeout(() => { + setIsVisible(false); + onClose(); + }, 300); + }, duration); + + return () => clearTimeout(timer); + }, [duration, onClose]); + + const handleClose = () => { + setIsClosing(true); + setTimeout(() => { + setIsVisible(false); + onClose(); + }, 300); + }; + if (!isVisible) return null; + return ( + + + + {message} +
+ +
+
+ ); +}; diff --git a/client/src/components/Toast/ToastContainer.style.ts b/client/src/components/Toast/ToastContainer.style.ts new file mode 100644 index 00000000..465927e1 --- /dev/null +++ b/client/src/components/Toast/ToastContainer.style.ts @@ -0,0 +1,11 @@ +import { css } from "@styled-system/css"; + +export const ToastContainerStyle = css({ + display: "flex", + zIndex: "9999", + position: "fixed", + right: "6", + bottom: "6", + gap: "2", + flexDirection: "column-reverse", +}); diff --git a/client/src/components/Toast/ToastContainer.tsx b/client/src/components/Toast/ToastContainer.tsx new file mode 100644 index 00000000..8cdd0f29 --- /dev/null +++ b/client/src/components/Toast/ToastContainer.tsx @@ -0,0 +1,23 @@ +import { AnimatePresence } from "framer-motion"; +import { useToastStore } from "@stores/useToastStore"; +import { Toast } from "./Toast"; +import { ToastContainerStyle } from "./ToastContainer.style"; + +export const ToastContainer = () => { + const { toasts, removeToast } = useToastStore(); + + return ( +
+ + {toasts.map((toast) => ( + removeToast(toast.id)} + /> + ))} + +
+ ); +}; diff --git a/client/src/components/sidebar/Sidebar.tsx b/client/src/components/sidebar/Sidebar.tsx index 6dcc886a..a9bb76a7 100644 --- a/client/src/components/sidebar/Sidebar.tsx +++ b/client/src/components/sidebar/Sidebar.tsx @@ -70,6 +70,7 @@ export const Sidebar = ({ sendPageDeleteOperation({ type: "pageDelete", workspaceId: "default", + pageTitle: pageToDelete!.title, pageId, clientId, }); diff --git a/client/src/components/sidebar/components/pageItem/PageItem.tsx b/client/src/components/sidebar/components/pageItem/PageItem.tsx index c1f43d53..e2df9f53 100644 --- a/client/src/components/sidebar/components/pageItem/PageItem.tsx +++ b/client/src/components/sidebar/components/pageItem/PageItem.tsx @@ -1,5 +1,5 @@ import { PageIconType } from "@noctaCrdt/Interfaces"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import CloseIcon from "@assets/icons/close.svg?react"; import { useModal } from "@src/components/modal/useModal"; import { PageIconButton } from "../pageIconButton/PageIconButton"; @@ -29,7 +29,11 @@ export const PageItem = ({ }: PageItemProps) => { const { isOpen, openModal, closeModal } = useModal(); const [pageIcon, setPageIcon] = useState(icon); - // 삭제 버튼 클릭 핸들러 + + useEffect(() => { + setPageIcon(icon); + }, [icon]); + const handleDelete = (e: React.MouseEvent) => { e.stopPropagation(); // 상위 요소로의 이벤트 전파 중단 onDelete?.(id); diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index 6951cbf2..45614aaa 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"; @@ -44,10 +44,16 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData sendBlockUpdateOperation, } = useSocketStore(); const { clientId } = useSocketStore(); + const [displayTitle, setDisplayTitle] = useState(pageTitle); + const [dragBlockList, setDragBlockList] = useState([]); - const [displayTitle, setDisplayTitle] = useState( - pageTitle === "새로운 페이지" || pageTitle === "" ? "" : pageTitle, - ); + useEffect(() => { + if (pageTitle === "새로운 페이지" || pageTitle === "") { + setDisplayTitle(""); + } else { + setDisplayTitle(pageTitle); + } + }, [pageTitle]); const editorCRDTInstance = useMemo(() => { let newEditorCRDT; @@ -80,7 +86,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 +278,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..e6fb2eac 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") { @@ -326,24 +354,27 @@ export const useMarkdownGrammer = ({ } case "Tab": { + if (e.nativeEvent.isComposing) return; e.preventDefault(); if (currentBlock) { 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; @@ -451,6 +482,7 @@ export const useMarkdownGrammer = ({ case "ArrowUp": case "ArrowDown": { + if (e.nativeEvent.isComposing) return; const hasPrevBlock = currentIndex > 0; const hasNextBlock = currentIndex < editorCRDT.LinkedList.spread().length - 1; if (e.key === "ArrowUp" && !hasPrevBlock) { @@ -487,6 +519,7 @@ export const useMarkdownGrammer = ({ } case "ArrowLeft": case "ArrowRight": { + if (e.nativeEvent.isComposing) return; // const selection = window.getSelection(); // const caretPosition = selection?.focusOffset || 0; const caretPosition = getAbsoluteCaretPosition(e.currentTarget); diff --git a/client/src/features/workSpace/WorkSpace.tsx b/client/src/features/workSpace/WorkSpace.tsx index b3d92c97..69168de2 100644 --- a/client/src/features/workSpace/WorkSpace.tsx +++ b/client/src/features/workSpace/WorkSpace.tsx @@ -5,6 +5,7 @@ import { BottomNavigator } from "@components/bottomNavigator/BottomNavigator"; import { ErrorModal } from "@components/modal/ErrorModal"; import { Sidebar } from "@components/sidebar/Sidebar"; import { Page } from "@features/page/Page"; +import { ToastContainer } from "@src/components/Toast/ToastContainer"; import { useSocketStore } from "@src/stores/useSocketStore"; import { workSpaceContainer, content } from "./WorkSpace.style"; import { IntroScreen } from "./components/IntroScreen"; @@ -45,6 +46,7 @@ export const WorkSpace = () => { return ( <> + {isLoading && }
{ if (!workspace) return; if (subscriptionRef.current) return; @@ -52,6 +54,7 @@ export const usePagesManage = (workspace: WorkSpace | null, clientId: number | n addPage(newPage); }, onRemotePageDelete: (operation) => { + addToast(`${operation.clientId}번 유저가 페이지(${operation.pageTitle})를 삭제하였습니다.`); workspace.remotePageDelete?.({ pageId: operation.pageId, workspaceId: operation.workspaceId, diff --git a/client/src/stores/useToastStore.ts b/client/src/stores/useToastStore.ts new file mode 100644 index 00000000..56758a24 --- /dev/null +++ b/client/src/stores/useToastStore.ts @@ -0,0 +1,29 @@ +import { create } from "zustand"; +import { Toast } from "../types/toast"; + +interface ToastStore { + toasts: Toast[]; + addToast: (message: string, duration?: number) => void; + removeToast: (id: number) => void; +} + +export const useToastStore = create((set) => ({ + toasts: [], + addToast: (message, duration = 3000) => { + const id = Date.now(); + set((state) => ({ + toasts: [...state.toasts, { id, message, duration }], + })); + // duration 후에 자동으로 해당 toast 제거 + setTimeout(() => { + set((state) => ({ + toasts: state.toasts.filter((toast) => toast.id !== id), + })); + }, duration); + }, + + removeToast: (id) => + set((state) => ({ + toasts: state.toasts.filter((toast) => toast.id !== id), + })), +})); diff --git a/client/src/types/toast.ts b/client/src/types/toast.ts new file mode 100644 index 00000000..b4413d88 --- /dev/null +++ b/client/src/types/toast.ts @@ -0,0 +1,5 @@ +export interface Toast { + id: number; + message: string; + duration: number; +} diff --git a/server/src/crdt/crdt.gateway.ts b/server/src/crdt/crdt.gateway.ts index e1488724..7fc774cd 100644 --- a/server/src/crdt/crdt.gateway.ts +++ b/server/src/crdt/crdt.gateway.ts @@ -280,13 +280,14 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa if (pageIndex === -1) { throw new Error(`Page with id ${data.pageId} not found`); } - // pageList에서 페이지 제거 + const pageTitle = (await this.workSpaceService.getPage(userId, data.pageId)).title; currentWorkspace.pageList.splice(pageIndex, 1); const operation = { type: "pageDelete", workspaceId: data.workspaceId, pageId: data.pageId, + pageTitle, clientId: data.clientId, } as RemotePageDeleteOperation; client.emit("delete/page", operation);