Skip to content
Open
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
9,405 changes: 9,405 additions & 0 deletions Syncly/package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Syncly/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
"stompjs": "^2.3.3",
"tailwind-merge": "^3.2.0",
"tailwind-scrollbar-hide": "^2.0.0",
"y-websocket": "^1.5.0",
"yjs": "^13.6.0",
"yup": "^1.6.1",
"zustand": "^5.0.3"
},
Expand Down
688 changes: 472 additions & 216 deletions Syncly/src/components/Note/DetailedNote.tsx

Large diffs are not rendered by default.

122 changes: 76 additions & 46 deletions Syncly/src/shared/api/webSocketService.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import * as Stomp from "stompjs";
import {
TWebSocketMessage,
TEditOperation,
TEnterPayload,
TEditPayload,
TCursorPayload,
TSavePayload,
} from "../type/note";

Expand Down Expand Up @@ -185,110 +182,143 @@ export class NoteWebSocketService {
}

/**
* 실시간 편집 메시지 구독 (EDIT)
* Yjs Update 전송 (STOMP를 통한 수동 동기화)
* @param noteId 노트 ID
* @param onEdit 편집 메시지 핸들러
* @param base64Update Base64 인코딩된 Yjs Update
*/
subscribeToEdits(
sendYjsUpdate(noteId: number, base64Update: string): void {
if (!this.stompClient?.connected) {
console.warn("⚠️ WebSocket이 연결되지 않았습니다. Update 전송 생략");
return;
}

const destination = `/app/notes/${noteId}/edit`;
const message = { base64Update };

console.log(`📤 Yjs Update 전송: ${destination}`, {
updateSize: base64Update.length,
});

try {
this.stompClient?.send(destination, {}, JSON.stringify(message));
} catch (error) {
console.error("❌ Yjs Update 전송 실패:", error);
}
}

/**
* Yjs Update 브로드캐스트 메시지 구독
* @param noteId 노트 ID
* @param onUpdate Update 수신 핸들러
*/
subscribeToYjsUpdates(
noteId: number,
onEdit: (payload: TEditPayload) => void
onUpdate: (base64Update: string, userName: string) => void
): void {
if (!this.stompClient?.connected) {
throw new Error("WebSocket이 연결되지 않았습니다.");
}

const topic = `/topic/notes/${noteId}/edits`;
if (this.subscriptions.has(topic)) {
console.log(`♻️ 기존 Update 구독 해제: ${topic}`);
this.subscriptions.get(topic)?.unsubscribe();
}

const subscription = this.stompClient?.subscribe(topic, (message) => {
try {
const response = JSON.parse(message.body);
console.log("📨 EDIT 메시지 수신:", response);
onEdit(response.payload);
console.log("📨 Yjs Update 브로드캐스트 수신:", {
type: response.type,
payloadSize: JSON.stringify(response.payload).length,
userName: response.payload?.userName
});
const payload = response.payload;
if (!payload.base64Update) {
console.warn("⚠️ base64Update 필드 없음:", payload);
return;
}
onUpdate(payload.base64Update, payload.userName);
} catch (error) {
console.error("❌ EDIT 메시지 파싱 오류:", error);
console.error("❌ Yjs Update 메시지 파싱 오류:", error, message.body);
}
});

if (subscription) {
this.subscriptions.set(topic, subscription);
console.log(`📨 편집 구독 시작: ${topic}`);
}
}

/**
* 편집 연산 전송 (EDIT)
* @param noteId 노트 ID
* @param operation 편집 연산
*/
sendEdit(noteId: number, operation: TEditOperation): void {
if (!this.stompClient?.connected) {
throw new Error("WebSocket이 연결되지 않았습니다.");
}

const destination = `/app/notes/${noteId}/edit`;
const message = { operation };

console.log(`📤 편집 전송:`, message);
try {
this.stompClient?.send(destination, {}, JSON.stringify(message));
} catch (error) {
console.error("❌ EDIT 메시지 전송 실패:", error);
throw new Error("편집 전송 실패");
console.log(`✅ Yjs Update 구독 시작: ${topic}`);
} else {
console.error(`❌ Yjs Update 구독 실패: ${topic}`);
}
}

/**
* 커서 위치 메시지 구독 (CURSOR)
* 원격 커서 위치 변경 메시지 구독 (STOMP)
* @param noteId 노트 ID
* @param onCursor 커서 메시지 핸들러
* @param onCursorChange 커서 변경 핸들러
*/
subscribeToCursors(
noteId: number,
onCursor: (payload: TCursorPayload) => void
onCursorChange: (cursor: {
position: number;
range: number;
workspaceMemberId: number;
userName: string;
profileImage?: string;
color: string;
}) => void
): void {
if (!this.stompClient?.connected) {
throw new Error("WebSocket이 연결되지 않았습니다.");
console.warn("⚠️ WebSocket이 연결되지 않았습니다.");
return;
}

const topic = `/topic/notes/${noteId}/cursors`;
if (this.subscriptions.has(topic)) {
console.log(`♻️ 기존 커서 구독 해제: ${topic}`);
this.subscriptions.get(topic)?.unsubscribe();
}

const subscription = this.stompClient?.subscribe(topic, (message) => {
try {
const response = JSON.parse(message.body);
console.log("📨 CURSOR 메시지 수신:", response);
onCursor(response.payload);
const cursor = JSON.parse(message.body);
console.log("📍 원격 커서 수신:", {
userName: cursor.userName,
position: cursor.position,
range: cursor.range
});
onCursorChange(cursor);
} catch (error) {
console.error("❌ CURSOR 메시지 파싱 오류:", error);
console.error("❌ 커서 메시지 파싱 오류:", error);
}
});

if (subscription) {
this.subscriptions.set(topic, subscription);
console.log(`📨 커서 구독 시작: ${topic}`);
console.log(` 커서 구독 시작: ${topic}`);
}
}

/**
* 커서 위치 업데이트 전송 (CURSOR)
* 로컬 커서 위치 전송
* @param noteId 노트 ID
* @param position 커서 위치
* @param range 선택 범위
*/
sendCursor(noteId: number, position: number, range: number = 0): void {
sendCursor(noteId: number, position: number, range: number): void {
if (!this.stompClient?.connected) {
throw new Error("WebSocket이 연결되지 않았습니다.");
console.warn("⚠️ WebSocket이 연결되지 않았습니다.");
return;
}

const destination = `/app/notes/${noteId}/cursor`;
const message = { position, range };

this.stompClient?.send(destination, {}, JSON.stringify(message));
try {
this.stompClient?.send(destination, {}, JSON.stringify(message));
} catch (error) {
console.error("❌ 커서 전송 실패:", error);
}
}

/**
Expand Down
64 changes: 25 additions & 39 deletions Syncly/src/shared/type/note.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { TUser } from "./FilesType";
// ✅ 사용되지 않는 TUser import 제거 (레거시 호환성 필드 삭제됨)

/**
* Note 기본 타입 (목록, 상세 조회)
* Note 기본 타입 (목록, 상세 조회) - Yjs CRDT 기반
*
* <p>Yjs CRDT로 전환되면서:
* - content 필드는 API의 ydocBinary(Base64)가 전달됨 (HTTP는 binary를 직접 전송 불가)
* - 프론트엔드에서 content ← ydocBinary → Yjs로 복원
* - OT 기반 revision/version 개념 제거
*/
export type TNotes = {
id: number;
title: string;
content: string;
content: string; // ✅ 실제로는 ydocBinary(Base64)가 전달됨
workspaceId: number;
creatorId: number;
creatorName: string;
Expand All @@ -15,9 +20,6 @@ export type TNotes = {
createdAt: string;
participantCount: number;
activeParticipants?: TNoteParticipant[];
// 레거시 호환성
date?: string;
user?: TUser;
};

/**
Expand Down Expand Up @@ -61,28 +63,17 @@ export type TNoteListResponse = {
};

/**
* 노트 저장 응답
* 노트 저장 응답 (Yjs CRDT 기반)
*
* <p>Yjs는 CRDT 기반이므로 revision 개념이 없습니다.
* 자동으로 충돌을 해결하므로 버전 관리가 필요 없습니다.
*/
export type TNoteSaveResponse = {
success: boolean;
revision: number;
savedAt: string;
message: string;
};

/**
* 편집 연산 (OT)
*/
export type TEditOperation = {
type: "insert" | "delete";
position: number;
length: number;
content?: string; // insert 시만
revision: number;
workspaceMemberId: number;
timestamp: string;
};

/**
* 커서 위치 정보
*/
Expand Down Expand Up @@ -117,28 +108,25 @@ export type TWebSocketMessage<T = unknown> = {
};

/**
* ENTER 페이로드
* 활성 사용자 정보
*/
export type TEnterPayload = {
noteId: number;
creatorName: string;
creatorProfileImage: string;
revision: number;
activeUsers: number[];
cursors: Record<number, TCursorPosition>;
timestamp: string;
export type TActiveUserInfo = {
workspaceMemberId: number;
userName: string;
profileImage?: string;
color: string;
};

/**
* EDIT 페이로드
* ENTER 페이로드
*/
export type TEditPayload = {
operation: TEditOperation;
content?: string; // 10번째 연산마다만
revision: number;
userName: string;
export type TEnterPayload = {
noteId: number;
title: string;
ydocBinary: string;
activeUsers: TActiveUserInfo[];
currentUserWorkspaceMemberId: number; // 현재 입장한 사용자의 WorkspaceMember ID
timestamp: string;
includesFullContent: boolean;
};

/**
Expand All @@ -152,7 +140,6 @@ export type TCursorPayload = TCursorPosition & {
* SAVE 페이로드
*/
export type TSavePayload = {
revision: number;
savedAt: string;
message: string;
};
Expand All @@ -164,6 +151,5 @@ export type TErrorPayload = {
code: string;
message: string;
content?: string; // 동기화용
revision?: number;
timestamp: string;
};
Loading