diff --git a/frontend/package.json b/frontend/package.json index 4a91d1b5e..037c7ef2b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,9 +10,11 @@ "cz": "cz" }, "dependencies": { + "@monaco-editor/react": "^4.7.0", "clsx": "^2.1.1", "framer-motion": "^12.23.26", "konva": "^10.0.12", + "monaco-editor": "^0.55.1", "next": "16.0.10", "react": "19.2.1", "react-dom": "19.2.1", @@ -20,6 +22,9 @@ "tailwind-merge": "^3.4.0", "use-image": "^1.1.4", "uuid": "^13.0.0", + "y-monaco": "^0.1.6", + "y-websocket": "^3.0.0", + "yjs": "^13.6.29", "zustand": "^5.0.9" }, "devDependencies": { diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index b6d9fdc53..d776ba238 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@monaco-editor/react': + specifier: ^4.7.0 + version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -17,6 +20,9 @@ importers: konva: specifier: ^10.0.12 version: 10.0.12 + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 next: specifier: 16.0.10 version: 16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -38,6 +44,15 @@ importers: uuid: specifier: ^13.0.0 version: 13.0.0 + y-monaco: + specifier: ^0.1.6 + version: 0.1.6(monaco-editor@0.55.1)(yjs@13.6.29) + y-websocket: + specifier: ^3.0.0 + version: 3.0.0(yjs@13.6.29) + yjs: + specifier: ^13.6.29 + version: 13.6.29 zustand: specifier: ^5.0.9 version: 5.0.9(@types/react@19.2.7)(react@19.2.1) @@ -884,6 +899,16 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} + + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1169,6 +1194,9 @@ packages: '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/uuid@11.0.0': resolution: {integrity: sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==} deprecated: This is a stub types definition. uuid provides its own type definitions, so you do not need this installed. @@ -1727,6 +1755,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -2336,6 +2367,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -2409,6 +2443,11 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -2528,6 +2567,11 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2574,6 +2618,9 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + motion-dom@12.23.23: resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} @@ -3028,6 +3075,9 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -3298,9 +3348,32 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + y-monaco@0.1.6: + resolution: {integrity: sha512-sYRywMmcylt+Nupl+11AvizD2am06ST8lkVbUXuaEmrtV6Tf+TD4rsEm6u9YGGowYue+Vfg1IJ97SUP2J+PVXg==} + engines: {node: '>=12.0.0', npm: '>=6.0.0'} + peerDependencies: + monaco-editor: '>=0.20.0' + yjs: ^13.3.1 + + y-protocols@1.0.7: + resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + + y-websocket@3.0.0: + resolution: {integrity: sha512-mUHy7AzkOZ834T/7piqtlA8Yk6AchqKqcrCXjKW8J1w2lPtRDjz8W5/CvXz9higKAHgKRKqpI3T33YkRFLkPtg==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.5.6 + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yjs@13.6.29: + resolution: {integrity: sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -4297,6 +4370,17 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@monaco-editor/loader@1.7.0': + dependencies: + state-local: 1.0.7 + + '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@monaco-editor/loader': 1.7.0 + monaco-editor: 0.55.1 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.7.1 @@ -4554,6 +4638,9 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/trusted-types@2.0.7': + optional: true + '@types/uuid@11.0.0': dependencies: uuid: 13.0.0 @@ -5157,6 +5244,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -5943,6 +6034,8 @@ snapshots: isexe@2.0.0: {} + isomorphic.js@0.2.5: {} + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -6016,6 +6109,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + lightningcss-android-arm64@1.30.2: optional: true @@ -6111,6 +6208,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked@14.0.0: {} + math-intrinsics@1.1.0: {} mdn-data@2.0.28: {} @@ -6146,6 +6245,11 @@ snapshots: dependencies: minimist: 1.2.8 + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + motion-dom@12.23.23: dependencies: motion-utils: 12.23.6 @@ -6610,6 +6714,8 @@ snapshots: stable-hash@0.0.5: {} + state-local@1.0.7: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -6952,8 +7058,29 @@ snapshots: wrappy@1.0.2: {} + y-monaco@0.1.6(monaco-editor@0.55.1)(yjs@13.6.29): + dependencies: + lib0: 0.2.117 + monaco-editor: 0.55.1 + yjs: 13.6.29 + + y-protocols@1.0.7(yjs@13.6.29): + dependencies: + lib0: 0.2.117 + yjs: 13.6.29 + + y-websocket@3.0.0(yjs@13.6.29): + dependencies: + lib0: 0.2.117 + y-protocols: 1.0.7(yjs@13.6.29) + yjs: 13.6.29 + yallist@3.1.1: {} + yjs@13.6.29: + dependencies: + lib0: 0.2.117 + yocto-queue@0.1.0: {} zod-validation-error@4.0.2(zod@4.2.1): diff --git a/frontend/src/app/laboratory/page.tsx b/frontend/src/app/laboratory/page.tsx new file mode 100644 index 000000000..1c419e203 --- /dev/null +++ b/frontend/src/app/laboratory/page.tsx @@ -0,0 +1,14 @@ +import CodeEditor from '@/components/code-editor/CodeEditor'; + +export default function Laboratory() { + return ( + + ~ Code Editor ~ + + {/* 협업 코드 에디터 */} + + + + + ); +} diff --git a/frontend/src/components/code-editor/CodeEditor.tsx b/frontend/src/components/code-editor/CodeEditor.tsx new file mode 100644 index 000000000..fbc143846 --- /dev/null +++ b/frontend/src/components/code-editor/CodeEditor.tsx @@ -0,0 +1,206 @@ +'use client'; + +import Editor from '@monaco-editor/react'; +import type * as monaco from 'monaco-editor'; +import { useEffect, useRef, useState } from 'react'; +import * as Y from 'yjs'; +import { WebsocketProvider } from 'y-websocket'; + +type CodeEditorProps = { + language?: string; + autoComplete?: boolean; + minimap?: boolean; +}; + +export default function CodeEditor({ + language = 'typescript', + autoComplete = true, + minimap = true, +}: CodeEditorProps) { + const editorRef = useRef(null); + const cleanupRef = useRef<(() => void) | null>(null); + const providerRef = useRef(null); + + const [isAutoCompleted, setIsAutoCompleted] = useState(autoComplete); + const [isPresenter, setIsPresenter] = useState(false); + const [hasPresenter, setHasPresenter] = useState(false); + + const handleMount = async (editor: monaco.editor.IStandaloneCodeEditor) => { + editorRef.current = editor; + + const ydoc = new Y.Doc(); + const { MonacoBinding } = await import('y-monaco'); + + const provider = new WebsocketProvider( + 'ws://localhost:1234', + 'room-1', + ydoc, + ); + providerRef.current = provider; + + // 사용자 정보 동적 설정 + const userName = `User-${Math.floor(Math.random() * 100)}`; + + provider.awareness.setLocalStateField('user', { + name: userName, + role: 'viewer', // 기본은 viewer + }); + + const yText = ydoc.getText('monaco'); + + const model = editor.getModel(); + if (!model) return; + + // 양방향 바인딩 해주기 + const binding = new MonacoBinding( + yText, // 원본 데이터 + model, // 실제 에디터에 보이는 코드 + new Set([editor]), // 바인딩할 에디터 인스턴스들 + provider.awareness, // 여기서 다른 유저들의 위치 정보를 받아온다. + ); + + // 발표자 상태 변화 감지 + provider.awareness.on('change', () => { + const states = provider.awareness.getStates(); // Map + + // 발표자 찾기 + const presenterEntry = Array.from(states.entries()).find( + ([clientId, state]) => state.user?.role === 'presenter', + ); + + setHasPresenter(Boolean(presenterEntry)); + + // presenter의 clientID + const presenterClientId = presenterEntry?.[0]; + + // 나 자신이 발표자인지 + const amIPresenter = provider.awareness.clientID === presenterClientId; + setIsPresenter(amIPresenter); + + // 발표자가 있으면 다른 사람은 read-only + const readOnly = presenterClientId != null && !amIPresenter; + editor.updateOptions({ readOnly }); + }); + + cleanupRef.current = () => { + binding.destroy(); + provider.destroy(); + ydoc.destroy(); + }; + }; + + useEffect(() => { + return () => cleanupRef.current?.(); + }, []); + + // 자동완성 토글 + const toggleAutoComplete = () => setIsAutoCompleted((prev) => !prev); + + // 발표자 되기 + const becomePresenter = () => { + if (hasPresenter) { + alert('이미 누가 발표중이라니까'); + // TODO: toast message 띄워주기 + return; + } + if (!providerRef.current) return; + + providerRef.current.awareness.setLocalStateField('user', { + role: 'presenter', + name: + providerRef.current.awareness.getLocalState()?.user?.name ?? + 'anonymous', + }); + }; + + // 발표자 취소 + const cancelPresenter = () => { + if (!providerRef.current) return; + + // 역할을 viewer로 돌려놓기 + providerRef.current.awareness.setLocalStateField('user', { + role: 'viewer', + name: + providerRef.current.awareness.getLocalState()?.user?.name ?? + 'anonymous', + }); + }; + + return ( + + {/* 상단 컨트롤 */} + + + 자동완성 {isAutoCompleted ? 'ON' : 'OFF'} + + + {!isPresenter && ( + + 발표자 되기 + + )} + + {isPresenter && ( + + 발표자 취소 + + )} + + {hasPresenter && ( + + {isPresenter ? '편집 가능 (발표자)' : '읽기 전용 (참가자)'} + + )} + + + {/* 코드에디터 */} + + + ); +}