diff --git a/@noctaCrdt/Interfaces.ts b/@noctaCrdt/Interfaces.ts index d8762ced..40b7d0e3 100644 --- a/@noctaCrdt/Interfaces.ts +++ b/@noctaCrdt/Interfaces.ts @@ -5,6 +5,8 @@ import { EditorCRDT } from "./Crdt"; export type ElementType = "p" | "h1" | "h2" | "h3" | "ul" | "ol" | "li" | "checkbox" | "blockquote"; +export type AnimationType = "none" | "highlight" | "gradation"; + export interface InsertOperation { node: Block | Char; } diff --git a/@noctaCrdt/Node.ts b/@noctaCrdt/Node.ts index 12936a49..8591e586 100644 --- a/@noctaCrdt/Node.ts +++ b/@noctaCrdt/Node.ts @@ -1,6 +1,6 @@ // Node.ts import { NodeId, BlockId, CharId } from "./NodeId"; -import { ElementType } from "./Interfaces"; +import { AnimationType, ElementType } from "./Interfaces"; import { BlockCRDT } from "./Crdt"; export abstract class Node { @@ -43,7 +43,7 @@ export abstract class Node { export class Block extends Node { type: ElementType; indent: number; - animation: string; + animation: AnimationType; style: string[]; icon: string; crdt: BlockCRDT; @@ -52,7 +52,7 @@ export class Block extends Node { super(value, id); this.type = "p"; this.indent = 0; - this.animation = ""; + this.animation = "none"; this.style = []; this.icon = ""; this.crdt = new BlockCRDT(id.client); diff --git a/client/src/constants/option.ts b/client/src/constants/option.ts new file mode 100644 index 00000000..a31fe835 --- /dev/null +++ b/client/src/constants/option.ts @@ -0,0 +1,39 @@ +import { AnimationType, ElementType } from "@noctaCrdt/Interfaces"; + +export const OPTION_CATEGORIES = { + TYPE: { + 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" }, + ] as { id: ElementType; label: string }[], + }, + ANIMATION: { + id: "animation", + label: "애니메이션", + options: [ + { id: "none", label: "없음" }, + { id: "highlight", label: "하이라이트" }, + { id: "gradation", label: "그라데이션" }, + ] as { id: AnimationType; label: string }[], + }, + DUPLICATE: { + id: "duplicate", + label: "복제", + options: null, + }, + DELETE: { + id: "delete", + label: "삭제", + options: null, + }, +}; + +export type OptionCategory = keyof typeof OPTION_CATEGORIES; diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index f8424eb7..42956749 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -13,6 +13,7 @@ import { useSocketStore } from "@src/stores/useSocketStore.ts"; import { editorContainer, editorTitleContainer, editorTitle } from "./Editor.style"; import { Block } from "./components/block/Block.tsx"; import { useBlockDragAndDrop } from "./hooks/useBlockDragAndDrop"; +import { useBlockOptionSelect } from "./hooks/useBlockOption.ts"; import { useMarkdownGrammer } from "./hooks/useMarkdownGrammer"; interface EditorProps { @@ -55,6 +56,18 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr pageId, }); + const { handleTypeSelect, handleAnimationSelect, handleCopySelect, handleDeleteSelect } = + useBlockOptionSelect({ + editorCRDT: editorCRDT.current, + editorState, + setEditorState, + pageId, + sendBlockUpdateOperation, + sendBlockDeleteOperation, + sendBlockInsertOperation, + sendCharInsertOperation, + }); + const { handleKeyDown } = useMarkdownGrammer({ editorCRDT: editorCRDT.current, editorState, @@ -275,6 +288,10 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onInput={handleBlockInput} onKeyDown={handleKeyDown} onClick={handleBlockClick} + onAnimationSelect={handleAnimationSelect} + onTypeSelect={handleTypeSelect} + onCopySelect={handleCopySelect} + onDeleteSelect={handleDeleteSelect} /> ))} diff --git a/client/src/features/editor/components/MenuBlock/MenuBlock.style.ts b/client/src/features/editor/components/MenuBlock/MenuBlock.style.ts index e4349ca1..1dfa9696 100644 --- a/client/src/features/editor/components/MenuBlock/MenuBlock.style.ts +++ b/client/src/features/editor/components/MenuBlock/MenuBlock.style.ts @@ -1,11 +1,9 @@ -// MenuBlock.style.ts import { css } from "@styled-system/css"; export const menuBlockStyle = css({ display: "flex", zIndex: 1, - - position: "relative", // absolute에서 relative로 변경 + position: "relative", justifyContent: "center", alignItems: "center", width: "20px", @@ -16,7 +14,6 @@ export const menuBlockStyle = css({ _groupHover: { opacity: 1, }, - _active: { cursor: "grabbing", }, diff --git a/client/src/features/editor/components/MenuBlock/MenuBlock.tsx b/client/src/features/editor/components/MenuBlock/MenuBlock.tsx index 0d3cb320..9ea2ecfe 100644 --- a/client/src/features/editor/components/MenuBlock/MenuBlock.tsx +++ b/client/src/features/editor/components/MenuBlock/MenuBlock.tsx @@ -1,17 +1,89 @@ +import { AnimationType, ElementType } from "@noctaCrdt/Interfaces"; +import { useState, useRef } from "react"; import DraggableIcon from "@assets/icons/draggable.svg?url"; +import { useModal } from "@src/components/modal/useModal"; +import { OptionModal } from "../OptionModal/OptionModal"; import { menuBlockStyle, dragHandleIconStyle } from "./MenuBlock.style"; -interface MenuBlockProps { +export interface MenuBlockProps { attributes?: Record; listeners?: Record; + onAnimationSelect: (animation: AnimationType) => void; + onTypeSelect: (type: ElementType) => void; + onCopySelect: () => void; + onDeleteSelect: () => void; } -export const MenuBlock = ({ attributes, listeners }: MenuBlockProps) => { +export const MenuBlock = ({ + attributes, + listeners, + onAnimationSelect, + onTypeSelect, + onCopySelect, + onDeleteSelect, +}: MenuBlockProps) => { + const menuBlockRef = useRef(null); + + const [pressTime, setPressTime] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [menuBlockPosition, setMenuBlockPosition] = useState<{ top: number; right: number }>({ + top: 0, + right: 0, + }); + + const { isOpen, openModal, closeModal } = useModal(); + + const handlePressStart = () => { + const timer = setTimeout(() => { + setIsDragging(true); + }, 300); + + setPressTime(timer); + }; + + const handlePressEnd = () => { + if (pressTime) { + clearTimeout(pressTime); + setPressTime(null); + } + + if (!isDragging) { + if (menuBlockRef.current) { + const { top, right } = menuBlockRef.current.getBoundingClientRect(); + setMenuBlockPosition({ top, right }); + } + openModal(); + } + setIsDragging(false); + }; + + const modifiedListeners = { + ...listeners, + // dnd 이벤트 덮어쓰기 + onMouseDown: (e: React.MouseEvent) => { + handlePressStart(); + listeners?.onMouseDown?.(e); + }, + onMouseUp: (e: React.MouseEvent) => { + handlePressEnd(); + listeners?.onMouseUp?.(e); + }, + }; + return ( -
+
drag handle
+
); }; diff --git a/client/src/features/editor/components/OptionModal/OptionModal.animaiton.ts b/client/src/features/editor/components/OptionModal/OptionModal.animaiton.ts new file mode 100644 index 00000000..b8e12c8d --- /dev/null +++ b/client/src/features/editor/components/OptionModal/OptionModal.animaiton.ts @@ -0,0 +1,10 @@ +export const modal = { + initial: { + opacity: 0, + x: -5, + }, + animate: { + opacity: 1, + x: 0, + }, +}; diff --git a/client/src/features/editor/components/OptionModal/OptionModal.style.ts b/client/src/features/editor/components/OptionModal/OptionModal.style.ts new file mode 100644 index 00000000..b330fb84 --- /dev/null +++ b/client/src/features/editor/components/OptionModal/OptionModal.style.ts @@ -0,0 +1,29 @@ +import { css } from "@styled-system/css"; + +export const optionModal = css({ + zIndex: "10000", + position: "fixed", + borderRadius: "8px", + width: "160px", + padding: "8px", + background: "white", + boxShadow: "md", +}); + +export const optionButton = css({ + borderRadius: "8px", + width: "100%", + paddingBlock: "4px", + paddingInline: "8px", + textAlign: "left", + _hover: { + backgroundColor: "gray.100/40", + cursor: "pointer", + }, +}); + +export const modalContainer = css({ + display: "flex", + gap: "1", + flexDirection: "column", +}); diff --git a/client/src/features/editor/components/OptionModal/OptionModal.tsx b/client/src/features/editor/components/OptionModal/OptionModal.tsx new file mode 100644 index 00000000..d3936c01 --- /dev/null +++ b/client/src/features/editor/components/OptionModal/OptionModal.tsx @@ -0,0 +1,140 @@ +import { AnimationType, ElementType } from "@noctaCrdt/Interfaces"; +import { motion } from "framer-motion"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { OPTION_CATEGORIES, OptionCategory } from "@src/constants/option"; +import { modal } from "./OptionModal.animaiton"; +import { modalContainer, optionButton, optionModal } from "./OptionModal.style"; + +const ModalPortal = ({ children }: { children: React.ReactNode }) => { + return createPortal(children, document.body); +}; + +interface OptionModalProps { + isOpen: boolean; + onClose: () => void; + onAnimationSelect: (id: AnimationType) => void; + onTypeSelect: (label: ElementType) => void; + onCopySelect: () => void; + onDeleteSelect: () => void; + menuBlockPosition: { top: number; right: number }; +} + +export const OptionModal = ({ + isOpen, + onClose, + onAnimationSelect, + onTypeSelect, + onCopySelect, + onDeleteSelect, + menuBlockPosition, +}: OptionModalProps) => { + const [hoveredCategory, setHoveredCategory] = useState(null); + const modalRef = useRef(null); + const subModalRef = useRef(null); + + const handleMouseEnter = (category: OptionCategory) => { + if (OPTION_CATEGORIES[category].options) { + setHoveredCategory(category); + } else { + setHoveredCategory(null); + } + }; + + const handleCategoryClick = (category: OptionCategory) => { + if (!OPTION_CATEGORIES[category].options) { + if (category === "DUPLICATE") { + onCopySelect(); + } else if (category === "DELETE") { + onDeleteSelect(); + } + onClose(); + } + }; + + const handleOptionClick = (option: AnimationType | ElementType) => { + if (hoveredCategory === "ANIMATION") { + onAnimationSelect(option as AnimationType); + } else if (hoveredCategory === "TYPE") { + onTypeSelect(option as ElementType); + } + onClose(); + }; + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement; + const isMainModalClick = modalRef.current?.contains(target); + const isSubModalClick = subModalRef.current?.contains(target); + + if (!isMainModalClick && !isSubModalClick) { + setHoveredCategory(null); + onClose(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("pointerdown", handleClickOutside); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("pointerdown", handleClickOutside); + }; + }, [onClose]); + + return ( + + {isOpen && ( + <> + +
+ {Object.entries(OPTION_CATEGORIES).map(([key, category]) => ( + + ))} +
+
+ + {hoveredCategory && ( +
setHoveredCategory(null)} + > +
+ {OPTION_CATEGORIES[hoveredCategory].options?.map((option) => ( + + ))} +
+
+ )} + + )} +
+ ); +}; diff --git a/client/src/features/editor/components/block/Block.animation.ts b/client/src/features/editor/components/block/Block.animation.ts index c6cd97ff..a02b8e6a 100644 --- a/client/src/features/editor/components/block/Block.animation.ts +++ b/client/src/features/editor/components/block/Block.animation.ts @@ -1,16 +1,25 @@ +const none = { + initial: { + background: "transparent", + }, + animate: { + background: "transparent", + }, +}; + const highlight = { initial: { background: `linear-gradient(to right, - #BFBFFF95 0%, - #BFBFFF95 0%, + #BFBFFF70 0%, + #BFBFFF70 0%, transparent 0%, transparent 100% )`, }, animate: { background: `linear-gradient(to right, - #BFBFFF95 0%, - #BFBFFF95 100%, + #BFBFFF70 0%, + #BFBFFF70 100%, transparent 100%, transparent 100% )`, @@ -21,6 +30,50 @@ const highlight = { }, }; +const gradation = { + initial: { + background: `linear-gradient(to right, + #BFBFFF 0%, + #B0E2FF 50%, + #FFE4E1 100% + )`, + backgroundSize: "300% 100%", + backgroundPosition: "100% 0", + }, + animate: { + background: [ + `linear-gradient(to right, + #BFBFFF 0%, + #B0E2FF 50%, + #FFE4E1 100% + )`, + `linear-gradient(to right, + #FFE4E1 0%, + #BFBFFF 50%, + #B0E2FF 100% + )`, + `linear-gradient(to right, + #B0E2FF 0%, + #FFE4E1 50%, + #BFBFFF 100% + )`, + `linear-gradient(to right, + #BFBFFF 0%, + #B0E2FF 50%, + #FFE4E1 100% + )`, + ], + transition: { + duration: 3, + ease: "linear", + repeat: Infinity, + times: [0, 0.33, 0.66, 1], + }, + }, +}; + export const blockAnimation = { + none, highlight, + gradation, }; diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index ea732375..3baa4827 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -1,5 +1,6 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { AnimationType, ElementType } from "@noctaCrdt/Interfaces"; import { Block as CRDTBlock } from "@noctaCrdt/Node"; import { BlockId } from "@noctaCrdt/NodeId"; import { motion } from "framer-motion"; @@ -17,10 +18,25 @@ interface BlockProps { onInput: (e: React.FormEvent, block: CRDTBlock) => void; onKeyDown: (e: React.KeyboardEvent) => void; onClick: (blockId: BlockId, e: React.MouseEvent) => void; + onAnimationSelect: (blockId: BlockId, animation: AnimationType) => void; + onTypeSelect: (blockId: BlockId, type: ElementType) => void; + onCopySelect: (blockId: BlockId) => void; + onDeleteSelect: (blockId: BlockId) => void; } export const Block: React.FC = memo( - ({ id, block, isActive, onInput, onKeyDown, onClick }: BlockProps) => { + ({ + id, + block, + isActive, + onInput, + onKeyDown, + onClick, + onAnimationSelect, + onTypeSelect, + onCopySelect, + onDeleteSelect, + }: BlockProps) => { console.log("블록 초기화 상태", block); const blockRef = useRef(null); const blockCRDTRef = useRef(block); @@ -63,6 +79,22 @@ export const Block: React.FC = memo( // block.crdt.currentCaret }, [isActive, blockCRDTRef.current.crdt.currentCaret]); + const handleAnimationSelect = (animation: AnimationType) => { + onAnimationSelect(block.id, animation); + }; + + const handleTypeSelect = (type: ElementType) => { + onTypeSelect(block.id, type); + }; + + const handleCopySelect = () => { + onCopySelect(block.id); + }; + + const handleDeleteSelect = () => { + onDeleteSelect(block.id); + }; + return ( // TODO: eslint 규칙을 수정해야 할까? // TODO: ol일때 index 순서 처리 @@ -72,17 +104,24 @@ export const Block: React.FC = memo( style={{ transform: CSS.Transform.toString(transform), transition, - opacity: isDragging ? 0.5 : 1, + opacity: isDragging ? 0.5 : undefined, }} - initial={block?.animation && blockAnimation.highlight.initial} - animate={isAnimationStart && block?.animation && blockAnimation.highlight.animate} + initial={blockAnimation[block.animation || "none"].initial} + animate={isAnimationStart && blockAnimation[block.animation || "none"].animate} data-group > - +
= memo( }, ); -// 메모이제이션을 위한 displayName 설정 Block.displayName = "Block"; diff --git a/client/src/features/editor/hooks/useBlockOption.ts b/client/src/features/editor/hooks/useBlockOption.ts new file mode 100644 index 00000000..595c65e6 --- /dev/null +++ b/client/src/features/editor/hooks/useBlockOption.ts @@ -0,0 +1,141 @@ +import { BlockCRDT, EditorCRDT } from "@noctaCrdt/Crdt"; +import { + AnimationType, + ElementType, + RemoteBlockDeleteOperation, + RemoteBlockInsertOperation, + RemoteBlockUpdateOperation, + RemoteCharInsertOperation, +} from "@noctaCrdt/Interfaces"; +import { BlockId } from "@noctaCrdt/NodeId"; +import { BlockLinkedList } from "node_modules/@noctaCrdt/LinkedList"; +import { EditorStateProps } from "../Editor"; + +interface useBlockOptionSelectProps { + editorCRDT: EditorCRDT; + editorState: EditorStateProps; + setEditorState: React.Dispatch< + React.SetStateAction<{ + clock: number; + linkedList: BlockLinkedList; + currentBlock: BlockId | null; + }> + >; + pageId: string; + sendBlockUpdateOperation: (operation: RemoteBlockUpdateOperation) => void; + sendBlockDeleteOperation: (operation: RemoteBlockDeleteOperation) => void; + sendBlockInsertOperation: (operation: RemoteBlockInsertOperation) => void; + sendCharInsertOperation: (operation: RemoteCharInsertOperation) => void; +} + +export const useBlockOptionSelect = ({ + editorCRDT, + editorState, + setEditorState, + pageId, + sendBlockUpdateOperation, + sendBlockDeleteOperation, + sendBlockInsertOperation, + sendCharInsertOperation, +}: useBlockOptionSelectProps) => { + const handleTypeSelect = (blockId: BlockId, type: ElementType) => { + const block = editorState.linkedList.getNode(blockId); + if (!block) return; + + block.type = type; + editorCRDT.remoteUpdate(block, pageId); + + sendBlockUpdateOperation({ + node: block, + pageId, + }); + + setEditorState((prev) => ({ + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, + currentBlock: blockId || prev.currentBlock, + })); + }; + + const handleAnimationSelect = (blockId: BlockId, animation: AnimationType) => { + const block = editorState.linkedList.getNode(blockId); + if (!block) return; + + block.animation = animation; + editorCRDT.remoteUpdate(block, pageId); + + sendBlockUpdateOperation({ + node: block, + pageId, + }); + + setEditorState((prev) => ({ + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, + currentBlock: blockId || prev.currentBlock, + })); + }; + + const handleCopySelect = (blockId: BlockId) => { + const currentBlock = editorState.linkedList.getNode(blockId); + if (!currentBlock) return; + + const currentIndex = editorCRDT.LinkedList.spread().findIndex((block) => + 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, + pageId, + ); + sendCharInsertOperation(insertOperation); + }); + + // 여기서 update를 한번 더 해주면 된다. (block의 속성 (animation, type, style, icon)을 복사하기 위함) + sendBlockUpdateOperation({ + node: operation.node, + pageId, + }); + + setEditorState((prev) => ({ + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, + currentBlock: operation.node.id || prev.currentBlock, + })); + }; + + const handleDeleteSelect = (blockId: BlockId) => { + const currentIndex = editorCRDT.LinkedList.spread().findIndex((block) => + block.id.equals(blockId), + ); + sendBlockDeleteOperation(editorCRDT.localDelete(currentIndex, undefined, pageId)); + + setEditorState((prev) => ({ + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, + currentBlock: prev.currentBlock, + })); + }; + + return { + handleTypeSelect, + handleAnimationSelect, + handleCopySelect, + handleDeleteSelect, + }; +};