Skip to content

Commit

Permalink
Merge branch 'dev' into Feature/#011_순서있는_리스트_숫자_구현
Browse files Browse the repository at this point in the history
  • Loading branch information
Ludovico7 committed Nov 26, 2024
2 parents 9ca1659 + f85aa96 commit e7f9899
Show file tree
Hide file tree
Showing 13 changed files with 316 additions and 102 deletions.
1 change: 0 additions & 1 deletion client/src/components/sidebar/Sidebar.style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export const sidebarContainer = cx(
);
export const navWrapper = css({
display: "flex",
gap: "md",
flexDirection: "column",
width: "100%",
height: "calc(100% - 176px)",
Expand Down
12 changes: 10 additions & 2 deletions client/src/components/sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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();
Expand Down Expand Up @@ -100,6 +107,7 @@ export const Sidebar = ({
{...item}
onClick={() => handlePageItemClick(item.id)}
onDelete={() => confirmPageDelete(item)}
handleIconUpdate={handlePageUpdate}
/>
</motion.div>
))
Expand Down
17 changes: 15 additions & 2 deletions client/src/components/sidebar/components/pageItem/PageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PageIconType>(icon);
// 삭제 버튼 클릭 핸들러
Expand All @@ -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 (
<div className={pageItemContainer} onClick={onClick}>
<PageIconButton type={pageIcon ?? "Docs"} onClick={handleToggleModal} />
<span className={textBox}>{title}</span>
<span className={textBox}>{title || "새로운 페이지"}</span>
<span className={`delete_box ${deleteBox}`} onClick={handleDelete}>
<CloseIcon width={16} height={16} />
</span>
Expand Down
1 change: 1 addition & 0 deletions client/src/constants/option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
53 changes: 47 additions & 6 deletions client/src/features/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -113,12 +117,19 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData
);

const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// 낙관적업데이트
onTitleChange(e.target.value, false);
const newTitle = e.target.value;
setDisplayTitle(newTitle); // 로컬 상태 업데이트
onTitleChange(newTitle, false); // 낙관적 업데이트
};

const handleBlur = (e: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLDivElement>) => {
if (editorCRDT) {
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -492,7 +533,7 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData
placeholder="제목을 입력하세요..."
onChange={handleTitleChange}
onBlur={handleBlur}
defaultValue={pageTitle == "새로운 페이지" ? "" : pageTitle}
value={displayTitle}
className={editorTitle}
/>
<div style={{ height: "36px" }}></div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>) => {
e.preventDefault();
setHoveredType(null);
};

useEffect(() => {
Expand Down Expand Up @@ -176,7 +167,7 @@ export const TextOptionModal = ({
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
>
<div className={modalContainer} onClick={handleModalClick}>
<div className={modalContainer} onClick={handleModalClick} onMouseDown={handleModalClick}>
<button
className={optionButton}
onClick={onBoldSelect}
Expand Down Expand Up @@ -245,41 +236,32 @@ export const TextOptionModal = ({
</span>
</button>
{/* 텍스트 색상 버튼들 */}
<div
className={optionButton}
onMouseEnter={() => handleMouseEnter("text")}
onClick={() => handleClickButton("text")}
>
<div className={optionButton} onMouseEnter={() => handleMouseEnter("text")}>
<span className={optionButtonText}>A</span>
{hoveredType === "text" && (
<TextColorOptionModal
onColorSelect={handleTextColorClick}
position={{
top: 40,
left: 0,
}}
/>
)}
</div>
{/* 배경 색상 버튼들 */}
<div
className={optionButton}
onMouseEnter={() => handleMouseEnter("background")}
onClick={() => handleClickButton("background")}
>
<div className={optionButton} onMouseEnter={() => handleMouseEnter("background")}>
<span className={optionButtonText}>BG</span>

{hoveredType === "background" && (
<BackgroundColorOptionModal
onColorSelect={handleTextBackgroundSelect}
position={{
top: 40,
left: -53,
}}
/>
)}
</div>
</div>
{hoveredType === "text" && (
<TextColorOptionModal
onColorSelect={handleTextColorClick}
position={{
top: 44,
left: 84,
}}
/>
)}
{hoveredType === "background" && (
<BackgroundColorOptionModal
onColorSelect={handleTextBackgroundSelect}
position={{
top: 44,
left: 84,
}}
/>
)}
</motion.div>
</ModalPortal>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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(
<motion.div
ref={modalRef}
tabIndex={0}
className={optionModal}
style={{
left: `${position.left}px`,
top: `${position.top}px`,
outline: "none",
}}
initial={modal.initial}
animate={modal.animate}
onKeyDown={handleKeyDown}
>
<div className={modalContainer}>
{options.map((option, index) => (
<button
key={option.id}
className={`${optionTypeButton} ${index === selectedIndex && "selected"}`}
onClick={() => {
onTypeSelect(option.id);
onClose();
}}
onMouseEnter={() => setSelectedIndex(index)}
>
{option.label}
</button>
))}
</div>
</motion.div>,
document.body,
);
};
Loading

0 comments on commit e7f9899

Please sign in to comment.