diff --git a/client/src/components/sidebar/Sidebar.style.ts b/client/src/components/sidebar/Sidebar.style.ts index d6566716..3dfbe9dc 100644 --- a/client/src/components/sidebar/Sidebar.style.ts +++ b/client/src/components/sidebar/Sidebar.style.ts @@ -13,7 +13,6 @@ export const sidebarContainer = cx( ); export const navWrapper = css({ display: "flex", - gap: "md", flexDirection: "column", width: "100%", height: "calc(100% - 176px)", diff --git a/client/src/components/sidebar/Sidebar.tsx b/client/src/components/sidebar/Sidebar.tsx index 24b1982b..867bb7a0 100644 --- a/client/src/components/sidebar/Sidebar.tsx +++ b/client/src/components/sidebar/Sidebar.tsx @@ -1,4 +1,5 @@ import { motion } from "framer-motion"; +import { PageIconType } from "node_modules/@noctaCrdt/Interfaces"; import { useState } from "react"; import { IconButton } from "@components/button/IconButton"; import { Modal } from "@components/modal/modal"; @@ -23,13 +24,19 @@ export const Sidebar = ({ pages, handlePageAdd, handlePageOpen, + handlePageUpdate, }: { pages: Page[]; handlePageAdd: () => void; handlePageOpen: ({ pageId }: { pageId: string }) => void; + handlePageUpdate: ( + pageId: string, + updates: { title?: string; icon?: PageIconType }, + syncWithServer: boolean, + ) => void; }) => { - const visiblePages = pages.filter((page) => page.isVisible); - const isMaxVisiblePage = visiblePages.length >= MAX_VISIBLE_PAGE; + const visiblePages = pages.filter((page) => page.isVisible && page.isLoaded); + const isMaxVisiblePage = visiblePages.length > MAX_VISIBLE_PAGE; const isSidebarOpen = useIsSidebarOpen(); const { toggleSidebar } = useSidebarActions(); const { isOpen, openModal, closeModal } = useModal(); @@ -100,6 +107,7 @@ export const Sidebar = ({ {...item} onClick={() => handlePageItemClick(item.id)} onDelete={() => confirmPageDelete(item)} + handleIconUpdate={handlePageUpdate} /> )) diff --git a/client/src/components/sidebar/components/pageItem/PageItem.tsx b/client/src/components/sidebar/components/pageItem/PageItem.tsx index d2702073..97930591 100644 --- a/client/src/components/sidebar/components/pageItem/PageItem.tsx +++ b/client/src/components/sidebar/components/pageItem/PageItem.tsx @@ -12,9 +12,21 @@ interface PageItemProps { icon: PageIconType; onClick: () => void; onDelete?: (id: string) => void; // 추가: 삭제 핸들러 + handleIconUpdate: ( + pageId: string, + updates: { title?: string; icon?: PageIconType }, + syncWithServer: boolean, + ) => void; } -export const PageItem = ({ id, icon, title, onClick, onDelete }: PageItemProps) => { +export const PageItem = ({ + id, + icon, + title, + onClick, + onDelete, + handleIconUpdate, +}: PageItemProps) => { const { isOpen, openModal, closeModal } = useModal(); const [pageIcon, setPageIcon] = useState(icon); // 삭제 버튼 클릭 핸들러 @@ -39,13 +51,14 @@ export const PageItem = ({ id, icon, title, onClick, onDelete }: PageItemProps) const handleSelectIcon = (e: React.MouseEvent, type: PageIconType) => { e.stopPropagation(); setPageIcon(type); + handleIconUpdate(id, { icon: type }, true); closeModal(); }; return (
- {title} + {title || "새로운 페이지"} diff --git a/client/src/constants/option.ts b/client/src/constants/option.ts index 010e7396..0cc4875e 100644 --- a/client/src/constants/option.ts +++ b/client/src/constants/option.ts @@ -13,6 +13,7 @@ export const OPTION_CATEGORIES = { { id: "ol", label: "순서 리스트" }, { id: "checkbox", label: "체크박스" }, { id: "blockquote", label: "인용문" }, + { id: "hr", label: "구분선" }, ] as { id: ElementType; label: string }[], }, ANIMATION: { diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index 9f99ab51..c4f3ce0e 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -54,6 +54,10 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData } = useSocketStore(); const { clientId } = useSocketStore(); + const [displayTitle, setDisplayTitle] = useState( + pageTitle === "새로운 페이지" || pageTitle === "" ? "" : pageTitle, + ); + const editorCRDTInstance = useMemo(() => { let newEditorCRDT; if (serializedEditorData) { @@ -113,12 +117,19 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData ); const handleTitleChange = (e: React.ChangeEvent) => { - // 낙관적업데이트 - onTitleChange(e.target.value, false); + const newTitle = e.target.value; + setDisplayTitle(newTitle); // 로컬 상태 업데이트 + onTitleChange(newTitle, false); // 낙관적 업데이트 }; const handleBlur = (e: React.ChangeEvent) => { - onTitleChange(e.target.value, true); + const newTitle = e.target.value; + if (newTitle === "") { + setDisplayTitle(""); // 입력이 비어있으면 로컬상태는 빈 문자열로 + onTitleChange("새로운 페이지", true); // 서버에는 "새로운 페이지"로 저장 + } else { + onTitleChange(newTitle, true); + } }; const handleBlockClick = (blockId: BlockId, e: React.MouseEvent) => { if (editorCRDT) { @@ -166,12 +177,37 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData charNode = block.crdt.localInsert(0, addedChar, block.id, pageId); } else if (caretPosition > currentContent.length) { // 맨 뒤에 삽입 + let prevChar; + if (currentContent.length > 0) { + prevChar = editorCRDT.current.currentBlock?.crdt.LinkedList.findByIndex( + currentContent.length - 1, + ); + } const addedChar = newContent[newContent.length - 1]; - charNode = block.crdt.localInsert(currentContent.length, addedChar, block.id, pageId); + charNode = block.crdt.localInsert( + currentContent.length, + addedChar, + block.id, + pageId, + prevChar ? prevChar.style : [], + prevChar ? prevChar.color : undefined, + prevChar ? prevChar.backgroundColor : undefined, + ); } else { // 중간에 삽입 + const prevChar = editorCRDT.current.currentBlock?.crdt.LinkedList.findByIndex( + validCaretPosition - 1, + ); const addedChar = newContent[validCaretPosition - 1]; - charNode = block.crdt.localInsert(validCaretPosition - 1, addedChar, block.id, pageId); + charNode = block.crdt.localInsert( + validCaretPosition - 1, + addedChar, + block.id, + pageId, + prevChar?.style, + prevChar?.color, + prevChar?.backgroundColor, + ); } editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; sendCharInsertOperation({ node: charNode.node, blockId: block.id, pageId }); @@ -361,6 +397,11 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData useEffect(() => { if (!editorCRDT || !editorCRDT.current.currentBlock) return; + + const { activeElement } = document; + if (activeElement?.tagName.toLowerCase() === "input") { + return; // input에 포커스가 있으면 캐럿 위치 변경하지 않음 + } setCaretPosition({ blockId: editorCRDT.current.currentBlock.id, linkedList: editorCRDT.current.LinkedList, @@ -492,7 +533,7 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData placeholder="제목을 입력하세요..." onChange={handleTitleChange} onBlur={handleBlur} - defaultValue={pageTitle == "새로운 페이지" ? "" : pageTitle} + value={displayTitle} className={editorTitle} />
diff --git a/client/src/features/editor/components/OptionModal/OptionModal.style.ts b/client/src/features/editor/components/OptionModal/OptionModal.style.ts index b330fb84..33da2c52 100644 --- a/client/src/features/editor/components/OptionModal/OptionModal.style.ts +++ b/client/src/features/editor/components/OptionModal/OptionModal.style.ts @@ -22,6 +22,17 @@ export const optionButton = css({ }, }); +export const optionTypeButton = css({ + borderRadius: "8px", + width: "100%", + paddingBlock: "4px", + paddingInline: "8px", + textAlign: "left", + "&.selected": { + backgroundColor: "gray.100/40", + }, +}); + export const modalContainer = css({ display: "flex", gap: "1", diff --git a/client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx b/client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx index 926c5b42..0bc76180 100644 --- a/client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx +++ b/client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx @@ -56,18 +56,9 @@ export const TextOptionModal = ({ setHoveredType(type); }; - const handleClickButton = (type: "text" | "background") => { - if (hoveredType === type) { - setHoveredType(null); - } else { - setHoveredType(type); - } - }; - - const handleModalClick = () => { - if (hoveredType !== null) { - setHoveredType(null); - } + const handleModalClick = (e: React.MouseEvent) => { + e.preventDefault(); + setHoveredType(null); }; useEffect(() => { @@ -176,7 +167,7 @@ export const TextOptionModal = ({ animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 10 }} > -
+
{/* 텍스트 색상 버튼들 */} -
handleMouseEnter("text")} - onClick={() => handleClickButton("text")} - > +
handleMouseEnter("text")}> A - {hoveredType === "text" && ( - - )}
{/* 배경 색상 버튼들 */} -
handleMouseEnter("background")} - onClick={() => handleClickButton("background")} - > +
handleMouseEnter("background")}> BG - - {hoveredType === "background" && ( - - )}
+ {hoveredType === "text" && ( + + )} + {hoveredType === "background" && ( + + )} ); diff --git a/client/src/features/editor/components/TypeOptionModal/TypeOptionModal.tsx b/client/src/features/editor/components/TypeOptionModal/TypeOptionModal.tsx new file mode 100644 index 00000000..167a2b95 --- /dev/null +++ b/client/src/features/editor/components/TypeOptionModal/TypeOptionModal.tsx @@ -0,0 +1,94 @@ +import { motion } from "framer-motion"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { OPTION_CATEGORIES } from "@src/constants/option"; +import { modalContainer, optionModal, optionTypeButton } from "../OptionModal/OptionModal.style"; +import { modal } from "../OptionModal/OptionModal.animaiton"; +import { ElementType } from "node_modules/@noctaCrdt/Interfaces"; + +interface TypeOptionModalProps { + isOpen: boolean; + onClose: () => void; + onTypeSelect: (type: ElementType) => void; + position: { top: number; left: number }; +} + +export const TypeOptionModal = ({ + isOpen, + onClose, + onTypeSelect, + position, +}: TypeOptionModalProps) => { + const modalRef = useRef(null); + const [selectedIndex, setSelectedIndex] = useState(0); + const { + TYPE: { options }, + } = OPTION_CATEGORIES; + + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((prev) => (prev <= 0 ? options.length - 1 : prev - 1)); + break; + + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((prev) => (prev >= options.length - 1 ? 0 : prev + 1)); + break; + + case "Enter": + e.preventDefault(); + onTypeSelect(options[selectedIndex].id); + + onClose(); + break; + + case "Escape": + e.preventDefault(); + onClose(); + break; + } + }; + + useEffect(() => { + if (isOpen && modalRef.current) { + modalRef.current.focus(); + } + }, [isOpen]); + + if (!isOpen) return null; + + return createPortal( + +
+ {options.map((option, index) => ( + + ))} +
+
, + document.body, + ); +}; diff --git a/client/src/features/editor/components/block/Block.style.ts b/client/src/features/editor/components/block/Block.style.ts index 133ccd57..cb9a8b20 100644 --- a/client/src/features/editor/components/block/Block.style.ts +++ b/client/src/features/editor/components/block/Block.style.ts @@ -74,7 +74,7 @@ export const textContainerStyle = cva({ p: { textStyle: "display-medium16", fontWeight: "normal", - "&:empty::before": { + "&:empty:focus::before": { content: '"텍스트를 입력하세요..."', }, }, diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index 1f2717f7..d3e95fe8 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -18,6 +18,7 @@ import { setInnerHTML, getTextOffset } from "../../utils/domSyncUtils"; import { IconBlock } from "../IconBlock/IconBlock"; 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"; @@ -86,6 +87,9 @@ export const Block: React.FC = memo( }, }); + const [slashModalOpen, setSlashModalOpen] = useState(false); + const [slashModalPosition, setSlashModalPosition] = useState({ top: 0, left: 0 }); + const handleInput = (e: React.FormEvent) => { const currentElement = e.currentTarget; // 텍스트를 삭제하면
태그가 생김 @@ -115,7 +119,20 @@ export const Block: React.FC = memo( const caretPosition = getAbsoluteCaretPosition(e.currentTarget); block.crdt.currentCaret = caretPosition; - onInput(e, block); + + const element = e.currentTarget; + const newContent = element.textContent || ""; + + if (newContent === "/" && !slashModalOpen) { + const rect = e.currentTarget.getBoundingClientRect(); + setSlashModalPosition({ + top: rect.top, + left: rect.left + 0, + }); + setSlashModalOpen(true); + } else { + onInput(e, block); + } }; const handleAnimationSelect = (animation: AnimationType) => { @@ -258,6 +275,12 @@ export const Block: React.FC = memo( onTextColorSelect={handleTextColorSelect} onTextBackgroundColorSelect={handleTextBackgroundColorSelect} /> + setSlashModalOpen(false)} + onTypeSelect={(type) => handleTypeSelect(type)} + position={slashModalPosition} + /> ); }, diff --git a/client/src/features/editor/hooks/useBlockOption.ts b/client/src/features/editor/hooks/useBlockOption.ts index 917e2b00..f1c7ef61 100644 --- a/client/src/features/editor/hooks/useBlockOption.ts +++ b/client/src/features/editor/hooks/useBlockOption.ts @@ -10,6 +10,7 @@ import { import { BlockId } from "@noctaCrdt/NodeId"; import { BlockLinkedList } from "node_modules/@noctaCrdt/LinkedList"; import { EditorStateProps } from "../Editor"; +import { Block } from "@noctaCrdt/Node"; interface useBlockOptionSelectProps { editorCRDT: EditorCRDT; @@ -87,39 +88,51 @@ export const useBlockOptionSelect = ({ block.id.equals(blockId), ); - const operation = editorCRDT.localInsert(currentIndex + 1, ""); - operation.node.type = currentBlock.type; - operation.node.indent = currentBlock.indent; - operation.node.animation = currentBlock.animation; - operation.node.style = currentBlock.style; - operation.node.icon = currentBlock.icon; - operation.node.crdt = new BlockCRDT(editorCRDT.client); - - // 먼저 새로운 블록을 만들고 - sendBlockInsertOperation({ node: operation.node, pageId }); - - // 내부 문자 노드 복사 - currentBlock.crdt.LinkedList.spread().forEach((char, index) => { - const insertOperation = operation.node.crdt.localInsert( - index, - char.value, - operation.node.id, + const copyBlock = (block: Block, targetIndex: number) => { + const operation = editorCRDT.localInsert(targetIndex, ""); + operation.node.type = block.type; + operation.node.indent = block.indent; + operation.node.animation = block.animation; + operation.node.style = block.style; + operation.node.icon = block.icon; + operation.node.crdt = new BlockCRDT(editorCRDT.client); + + // 먼저 새로운 블록을 만들고 + sendBlockInsertOperation({ node: operation.node, pageId }); + + // 내부 문자 노드 복사 + block.crdt.LinkedList.spread().forEach((char, index) => { + const insertOperation = operation.node.crdt.localInsert( + index, + char.value, + operation.node.id, + pageId, + ); + sendCharInsertOperation(insertOperation); + }); + + // 여기서 update를 한번 더 해주면 된다. (block의 속성 (animation, type, style, icon)을 복사하기 위함) + sendBlockUpdateOperation({ + node: operation.node, pageId, - ); - insertOperation.node.style = char.style; - insertOperation.node.color = char.color; - insertOperation.node.backgroundColor = char.backgroundColor; - sendCharInsertOperation(insertOperation); - }); + }); - // 여기서 update를 한번 더 해주면 된다. (block의 속성 (animation, type, style, icon)을 복사하기 위함) - sendBlockUpdateOperation({ - node: operation.node, - pageId, + return operation.node; + }; + + const childBlocks = editorCRDT.LinkedList.spread() + .slice(currentIndex + 1) + .filter((block) => block.indent > currentBlock.indent); + + let targetIndex = currentIndex + childBlocks.length + 1; + const copiedParent = copyBlock(currentBlock, targetIndex); + + childBlocks.forEach((child) => { + targetIndex += 1; + copyBlock(child, targetIndex); }); - editorCRDT.currentBlock = operation.node; - // ol 노드의 index를 다시 설정 + editorCRDT.currentBlock = copiedParent; editorCRDT.LinkedList.updateAllOrderedListIndices(); setEditorState({ clock: editorCRDT.clock, @@ -134,10 +147,28 @@ export const useBlockOptionSelect = ({ }; const handleDeleteSelect = (blockId: BlockId) => { - const currentIndex = editorCRDT.LinkedList.spread().findIndex((block) => - block.id.equals(blockId), - ); - sendBlockDeleteOperation(editorCRDT.localDelete(currentIndex, undefined, pageId)); + const blocks = editorCRDT.LinkedList.spread(); // spread() 한 번만 호출 + const currentIndex = blocks.findIndex((block) => block.id.equals(blockId)); + + const currentBlock = blocks[currentIndex]; + if (!currentBlock) return; + + const deleteIndices = []; + const currentIndent = currentBlock.indent; + + // 현재 블록과 자식 블록들의 인덱스를 한 번에 수집 + for (let i = currentIndex; i < blocks.length; i++) { + if (i === currentIndex || blocks[i].indent > currentIndent) { + deleteIndices.push(i); + } else if (blocks[i].indent <= currentIndent) { + break; // 더 이상 자식 블록이 없으면 종료 + } + } + + // 인덱스 역순으로 삭제 + for (let i = deleteIndices.length - 1; i >= 0; i--) { + sendBlockDeleteOperation(editorCRDT.localDelete(deleteIndices[i], undefined, pageId)); + } // 삭제할 블록이 현재 활성화된 블록인 경우 if (editorCRDT.currentBlock?.id.equals(blockId)) { diff --git a/client/src/features/workSpace/WorkSpace.tsx b/client/src/features/workSpace/WorkSpace.tsx index ec231859..f1589a38 100644 --- a/client/src/features/workSpace/WorkSpace.tsx +++ b/client/src/features/workSpace/WorkSpace.tsx @@ -25,7 +25,7 @@ export const WorkSpace = () => { initPagePosition, openPage, } = usePagesManage(workspace, clientId); - const visiblePages = pages.filter((page) => page.isVisible); + const visiblePages = pages.filter((page) => page.isVisible && page.isLoaded); useEffect(() => { if (workspaceMetadata) { @@ -58,21 +58,24 @@ export const WorkSpace = () => { opacity: isInitialized && !isLoading ? 1 : 0, })} > - +
- {visiblePages.map((page) => - page.isLoaded ? ( - - ) : null, - )} + {visiblePages.map((page) => ( + + ))}
- +
); diff --git a/client/src/features/workSpace/hooks/usePagesManage.ts b/client/src/features/workSpace/hooks/usePagesManage.ts index e8f8b963..b42948bc 100644 --- a/client/src/features/workSpace/hooks/usePagesManage.ts +++ b/client/src/features/workSpace/hooks/usePagesManage.ts @@ -172,6 +172,14 @@ export const usePagesManage = (workspace: WorkSpace | null, clientId: number | n : { ...p, isActive: false }, ), ); + + setTimeout(() => { + const titleInput = document.querySelector(`#${CSS.escape(pageId)} input`); + console.log(titleInput); + if (titleInput instanceof HTMLInputElement) { + titleInput.focus(); + } + }, 0); } }; const closePage = (pageId: string) => {