Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"@repo/ui": "workspace:*",
"@tanstack/react-query": "^5.90.8",
"json-server": "^1.0.0-beta.3",
"next": "15.5.6",
"next": "15.5.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"zustand": "^5.0.8"
Expand Down
45 changes: 45 additions & 0 deletions apps/editor/src/stores/useEditorStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { create } from "zustand";
import { combine, devtools } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";

const useEditorStore = create(
devtools(
immer(
combine(
{
selectedNodeId: null as string | null,
},
(set) => ({
selectNode(id: string) {
set(
(state) => {
state.selectedNodeId = id;
},
false,
"editorStore/selectNode",
);
},
clearNode() {
set(
(state) => {
state.selectedNodeId = null;
},
false,
"editorStore/clearNode",
);
},
}),
),
),
{
name: "editorStore",
},
),
);

export const useSelectedNodeId = () =>
useEditorStore((store) => store.selectedNodeId);

export const useSelectNode = () => useEditorStore((store) => store.selectNode);

export const useClearNode = () => useEditorStore((store) => store.clearNode);
5 changes: 4 additions & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@
"type-check": "tsc --noEmit"
},
"peerDependencies": {
"next": "15.5.6",
"next": "15.5.9",
"react": "19.1.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"typescript": "^5"
},
"dependencies": {
"framer-motion": "^12.23.26"
}
}
105 changes: 40 additions & 65 deletions packages/ui/src/components/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,30 @@
import { useBuilderMode } from "context/builderMode";
import { useRuntimeState } from "context/runtimeContext";
import { useRef } from "react";
import { ModalNode, NodeComponentProps } from "types";
import processNodeStyles from "utils/processNodeStyles";
import { motion } from "framer-motion";

const animationVariants = {
fade: {
hidden: { opacity: 0 },
visible: { opacity: 1 },
exit: { opacity: 0 },
},
"slide-up": {
hidden: { opacity: 0, y: 100 },
visible: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 100 },
},
"slide-right": {
hidden: { opacity: 0, x: 100 },
visible: { opacity: 1, x: 0 },
exit: { opacity: 0, x: 100 },
},
// 기본값 (Zoom In)
default: {
hidden: { opacity: 0, scale: 0.9 },
visible: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.9 },
},
};

/**
*
Expand All @@ -13,77 +35,30 @@ import processNodeStyles from "utils/processNodeStyles";
*/
export default function Modal({
node,
props,
style,
children, //모달안에 들어갈 버튼, 텍스트 등이 children으로 올 수 있습니다.
}: NodeComponentProps<ModalNode>) {
const dialogRef = useRef<HTMLDialogElement>(null);

const curNodeId = node.id;
const animationType = node.props.animation || "default";

const { mode } = useBuilderMode();

//Context를 통한 상태 구독
//TODO-만약 context의 형태가 context API에서 zustand로 바뀐다면 해당 로직도 수정되야 합니다.
const { state, updateNodeState } = useRuntimeState();

const isOpen = state[curNodeId]?.isOpen ?? false;

const { title, showCloseButton, closeOnOverlayClick } = props;

//스타일 변환
const nodeStyleObj = processNodeStyles(style);

//닫기 핸들러
function closeHandler(e?: React.MouseEvent) {
if (mode === "editor") return;

e?.stopPropagation();

updateNodeState(curNodeId, { isOpen: false });
}

function overlayClickHandler(e?: React.MouseEvent<HTMLDialogElement>) {
if (props.closeOnOverlayClick === false) return;
if (e?.target !== dialogRef.current) return;
//오직 ::backdrop에 이벤트가 발생했을 경우에만 발생합니다.
closeHandler(e);
}

if (!isOpen) return null;

//굳이 div로 오버레이가 필요 없을듯..? ::backdrop과 dialog에서 제공하는 .showModal() 사용해보기
// (closeOnOverlayClick 존재 유무에 따라 div태그의 핸들러 동작을 결정하자.)

//기본적인 모달 태그는 dialog로 결정
//이때 배경 클릭시
return (
<dialog
ref={dialogRef}
<motion.div
data-component-id={curNodeId}
onClick={overlayClickHandler}
key={curNodeId}
onClick={(e) => e.stopPropagation()}
className={`${style.root?.className || ""} pointer-events-auto relative bg-white shadow-2xl`}
style={nodeStyleObj.root}
variants={animationVariants[animationType as keyof typeof animationVariants] || animationVariants.default}
initial="hidden"
animate="visible"
exit="exit"
transition={{ duration: 0.2, ease: "easeOut" }}
>
{/* 실제 모달 컨테이너 */}
<div
onClick={(e) => e.stopPropagation()}
className={`${style.root?.className || ""} relative min-h-[200px] min-w-[300px] rounded-lg bg-white shadow-xl`}
style={nodeStyleObj.root}
>
{/* 모달 헤더 */}
{(props.title || props.showCloseButton) && (
<div
className={`${style.heading?.className || ""} flex items-center justify-between p-4`}
style={nodeStyleObj.heading}
>
{props.title && <h3>{props.title}</h3>}

{/* TODO-svg 공통 모듈 작업 필요 */}
{props.showCloseButton && <button>X</button>}
</div>
)}

{/* [Body] 자식 노드들(버튼, 이미지, 텍스트)이 여기에 렌더링 됨 */}
<div className="p-4">{children}</div>
</div>
</dialog>
{/* [Body] 자식 노드들(버튼, 이미지, 텍스트)이 여기에 렌더링 됨 */}
<div className="p-4">{children}</div>
</motion.div>
);
}
60 changes: 60 additions & 0 deletions packages/ui/src/components/ModalHost.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useRuntimeState } from "context/runtimeContext";
import { useProjectData } from "hooks/useProjectData";
import Modal from "./Modal";
import { ModalNode } from "types";
import { motion, AnimatePresence } from "framer-motion";

export default function ModalHost() {
//Context를 통한 상태 구독
//TODO-만약 context의 형태가 context API에서 zustand로 바뀐다면 해당 로직도 수정되야 합니다.
const { activeModalId, closeModal } = useRuntimeState();

//DB에서 관련 데이터 불러오기
const { modal: targetNode } = useProjectData(activeModalId);

// 닫기 핸들러
const handleClose = () => {
closeModal();
};

const handleOverlayClick = (closeOnOverlayClick?: boolean) => {
if (closeOnOverlayClick === false) return;
handleClose();
};

// 위치 프리셋
const alignmentStyles = {
center: "items-center justify-center",
top: "items-start justify-center pt-10",
bottom: "items-end justify-center pb-0",
left: "items-center justify-start h-full",
right: "items-center justify-end h-full",
};

return (
<AnimatePresence>
{activeModalId && targetNode && (
<motion.div
key="modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={`fixed inset-0 z-9999 flex ${alignmentStyles[(targetNode.props.alignment || "center") as keyof typeof alignmentStyles]} ${targetNode.props.overlayColor || "bg-black/50"}`}
onClick={() =>
handleOverlayClick(targetNode.props.closeOnOverlayClick)
}
>
<div onClick={(e) => e.stopPropagation()}>
<Modal
node={targetNode}
props={targetNode.props}
style={targetNode.style}
>
{/* Children */}
</Modal>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
Loading