Skip to content

Commit 6e6e944

Browse files
Tolerblancsummersummerwhyezcolin2
committed
refactor: yjs 서비스 재작성
- 기존 다른 서비스에 의존하고 있던 부분을 REST API 호출로 변경 Co-authored-by: Summer Min <[email protected]> Co-authored-by: ez <[email protected]>
1 parent 0354b64 commit 6e6e944

File tree

1 file changed

+363
-0
lines changed

1 file changed

+363
-0
lines changed
Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
import {
2+
OnGatewayConnection,
3+
OnGatewayDisconnect,
4+
OnGatewayInit,
5+
WebSocketGateway,
6+
WebSocketServer,
7+
} from '@nestjs/websockets';
8+
import { Logger } from '@nestjs/common';
9+
import { Server } from 'socket.io';
10+
import { YSocketIO } from 'y-socket.io/dist/server';
11+
import * as Y from 'yjs';
12+
import {
13+
yXmlFragmentToProsemirrorJSON,
14+
prosemirrorJSONToYXmlFragment,
15+
} from 'y-prosemirror';
16+
import { novelEditorSchema } from './yjs.schema';
17+
import { YMapEdge } from './yjs.type';
18+
import type { Node } from './types/node.entity';
19+
import type { Edge } from './types/edge.entity';
20+
import { RedisService } from '../redis/redis.service';
21+
import axios from 'axios';
22+
23+
// Y.Doc에는 name 컬럼이 없어서 생성했습니다.
24+
class CustomDoc extends Y.Doc {
25+
name: string;
26+
27+
constructor(name: string) {
28+
super();
29+
this.name = name;
30+
}
31+
}
32+
33+
@WebSocketGateway()
34+
export class YjsService
35+
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
36+
{
37+
private logger = new Logger(YjsService.name);
38+
private ysocketio: YSocketIO;
39+
40+
constructor(private readonly redisService: RedisService) {}
41+
42+
@WebSocketServer()
43+
server: Server;
44+
45+
afterInit() {
46+
if (!this.server) {
47+
this.logger.error('서버 초기화 안됨..!');
48+
this.server = new Server();
49+
}
50+
51+
this.ysocketio = new YSocketIO(this.server, {
52+
gcEnabled: true,
53+
});
54+
55+
this.ysocketio.initialize();
56+
57+
this.ysocketio.on('document-loaded', async (doc: Y.Doc) => {
58+
// Y.Doc에 name이 없어서 새로 만든 CustomDoc
59+
const editorDoc = doc.getXmlFragment('default');
60+
const customDoc = editorDoc.doc as CustomDoc;
61+
62+
// 만약 users document라면 초기화하지 않습니다.
63+
if (customDoc.name === 'users') {
64+
return;
65+
}
66+
67+
// document name이 flow-room이라면 모든 노드들을 볼 수 있는 화면입니다.
68+
// 노드를 클릭해 페이지를 열었을 때만 해당 페이지 값을 가져와서 초기 데이터로 세팅해줍니다.
69+
if (customDoc.name?.startsWith('document-')) {
70+
const pageId = parseInt(customDoc.name.split('-')[1]);
71+
this.initializePage(pageId, editorDoc);
72+
}
73+
74+
if (!customDoc.name?.startsWith('flow-room-')) {
75+
return;
76+
}
77+
78+
// TODO: workspaceId 파싱 로직 추가하기
79+
const workspaceId = 'main';
80+
// 만약 workspace document라면 node, edge 초기 데이터를 세팅해줍니다.
81+
this.initializeWorkspace(workspaceId, doc);
82+
});
83+
}
84+
85+
/**
86+
* yXmlFragment에 content를 넣어준다.
87+
*/
88+
private async initializePage(pageId: number, editorDoc: Y.XmlFragment) {
89+
// 초기 세팅할 page content
90+
let pageContent: JSON;
91+
92+
const response = await axios.get(`http://backend:3000/api/page/${pageId}`);
93+
if (response.status === 404) {
94+
this.logger.error(`${pageId}번 페이지를 찾을 수 없습니다.`);
95+
pageContent = JSON.parse('{}');
96+
return;
97+
}
98+
99+
const findPage = response.data.page;
100+
pageContent = JSON.parse(JSON.stringify(findPage.content));
101+
102+
// content가 비어있다면 내부 구조가 novel editor schema를 따르지 않기 때문에 오류가 납니다.
103+
// content가 존재할 때만 넣어줍니다.
104+
if (Object.keys(pageContent).length > 0) {
105+
this.transformText(pageContent);
106+
prosemirrorJSONToYXmlFragment(novelEditorSchema, pageContent, editorDoc);
107+
}
108+
109+
// 페이지 내용 변경 사항을 감지해서 데이터베이스에 갱신합니다.
110+
editorDoc.observeDeep(() => {
111+
this.observeEditor(editorDoc);
112+
});
113+
}
114+
115+
handleConnection() {
116+
this.logger.log('접속');
117+
}
118+
119+
handleDisconnect() {
120+
this.logger.log('접속 해제');
121+
}
122+
123+
/**
124+
* initialize 관련 메소드
125+
*/
126+
private async initializeWorkspace(workspaceId: string, doc: Y.Doc) {
127+
// workspaceId에 속한 모든 노드와 엣지를 가져온다.
128+
const nodeResponse = await axios.get(
129+
`http://backend:3000/api/node/workspace/${workspaceId}`,
130+
);
131+
const nodes = nodeResponse.data.nodes;
132+
133+
const edgeResponse = await axios.get(
134+
`http://backend:3000/api/edge/workspace/${workspaceId}`,
135+
);
136+
const edges = edgeResponse.data.edges;
137+
138+
const nodesMap = doc.getMap('nodes');
139+
const title = doc.getMap('title');
140+
const emoji = doc.getMap('emoji');
141+
const edgesMap = doc.getMap('edges');
142+
143+
this.initializeYNodeMap(nodes, nodesMap, title, emoji);
144+
this.initializeYEdgeMap(edges, edgesMap);
145+
146+
// title의 변경 사항을 감지한다.
147+
title.observeDeep(this.observeTitle.bind(this));
148+
149+
// emoji의 변경 사항을 감지한다.
150+
emoji.observeDeep(this.observeEmoji.bind(this));
151+
152+
// node의 변경 사항을 감지한다.
153+
nodesMap.observe((event) => {
154+
this.observeNodeMap(event, nodesMap);
155+
});
156+
157+
// edge의 변경 사항을 감지한다.
158+
edgesMap.observe(async (event) => {
159+
this.observeEdgeMap(event, edgesMap);
160+
});
161+
}
162+
163+
/**
164+
* YMap에 노드 정보를 넣어준다.
165+
*/
166+
private initializeYNodeMap(
167+
nodes: Node[],
168+
yNodeMap: Y.Map<unknown>,
169+
yTitleMap: Y.Map<unknown>,
170+
yEmojiMap: Y.Map<unknown>,
171+
): void {
172+
// Y.Map 초기화
173+
yNodeMap.clear();
174+
yTitleMap.clear();
175+
yEmojiMap.clear();
176+
177+
nodes.forEach((node) => {
178+
const nodeId = node.id.toString(); // id를 string으로 변환
179+
180+
// Y.Map에 데이터를 삽입
181+
yNodeMap.set(nodeId, {
182+
id: nodeId,
183+
type: 'note',
184+
data: {
185+
title: node.page.title,
186+
id: node.page.id,
187+
emoji: node.page.emoji,
188+
},
189+
position: {
190+
x: node.x,
191+
y: node.y,
192+
},
193+
selected: false, // 기본적으로 선택되지 않음
194+
dragging: true,
195+
isHolding: false,
196+
});
197+
198+
// Y.Text title에 데이터 삽입
199+
const pageId = node.page.id.toString(); // id를 string으로 변환
200+
const yTitleText = new Y.Text();
201+
yTitleText.insert(0, node.page.title);
202+
203+
// Y.Map에 데이터를 삽입
204+
yTitleMap.set(`title_${pageId}`, yTitleText);
205+
206+
// Y.Text emoji에 데이터 삽입
207+
const yEmojiText = new Y.Text();
208+
const emoji = node.page.emoji ?? '📄';
209+
yEmojiText.insert(0, emoji);
210+
211+
// Y.Map에 데이터를 삽입
212+
yEmojiMap.set(`emoji_${pageId}`, yEmojiText);
213+
});
214+
}
215+
216+
/**
217+
* yMap에 edge 정보를 넣어준다.
218+
*/
219+
private initializeYEdgeMap(edges: Edge[], yMap: Y.Map<unknown>): void {
220+
edges.forEach((edge) => {
221+
const edgeId = edge.id.toString(); // id를 string으로 변환
222+
223+
// Y.Map에 데이터를 삽입
224+
yMap.set(`e${edge.fromNode.id}-${edge.toNode.id}`, {
225+
id: edgeId,
226+
source: edge.fromNode.id.toString(),
227+
target: edge.toNode.id.toString(),
228+
sourceHandle: 'left',
229+
targetHandle: 'left',
230+
});
231+
});
232+
}
233+
234+
/**
235+
* event listener 관련
236+
*/
237+
private async observeTitle(event: Y.YEvent<any>[]) {
238+
// path가 존재할 때만 페이지 갱신
239+
event[0].path.toString().split('_')[1] &&
240+
this.redisService.setField(
241+
`page:${event[0].path.toString().split('_')[1]}`,
242+
'title',
243+
event[0].target.toString(),
244+
);
245+
}
246+
247+
private async observeEmoji(event: Y.YEvent<any>[]) {
248+
// path가 존재할 때만 페이지 갱신
249+
event[0].path.toString().split('_')[1] &&
250+
this.redisService.setField(
251+
`page:${event[0].path.toString().split('_')[1]}`,
252+
'emoji',
253+
event[0].target.toString(),
254+
);
255+
}
256+
257+
private async observeNodeMap(
258+
event: Y.YMapEvent<unknown>,
259+
nodesMap: Y.Map<unknown>,
260+
) {
261+
for (const [key, change] of event.changes.keys) {
262+
// TODO: change.action이 'add', 'delete'일 때 처리를 추가하여 REST API 사용 제거
263+
if (change.action !== 'update') continue;
264+
265+
const node: any = nodesMap.get(key);
266+
if (node.type !== 'note') continue;
267+
268+
// node.data는 페이지에 대한 정보
269+
const { id } = node.data;
270+
const { x, y } = node.position;
271+
const isHolding = node.isHolding;
272+
if (isHolding) continue;
273+
274+
// TODO : node의 경우 key 값을 page id가 아닌 node id로 변경
275+
// const findPage = await this.pageService.findPageById(id);
276+
// await this.nodeService.updateNode(findPage.node.id, {
277+
// title,
278+
// x,
279+
// y,
280+
// });
281+
const pageResponse = await axios.get(
282+
`http://backend:3000/api/page/${id}`,
283+
);
284+
const findPage = pageResponse.data.page;
285+
this.redisService.setField(`node:${findPage.node.id}`, 'x', x);
286+
this.redisService.setField(`node:${findPage.node.id}`, 'y', y);
287+
}
288+
}
289+
290+
private async observeEdgeMap(
291+
event: Y.YMapEvent<unknown>,
292+
edgesMap: Y.Map<unknown>,
293+
) {
294+
for (const [key, change] of event.changes.keys) {
295+
const edge = edgesMap.get(key) as YMapEdge;
296+
297+
if (change.action === 'add') {
298+
// 연결된 노드가 없을 때만 edge 생성
299+
this.redisService.setField(
300+
`edge:${edge.source}-${edge.target}`,
301+
'fromNode',
302+
edge.source,
303+
);
304+
this.redisService.setField(
305+
`edge:${edge.source}-${edge.target}`,
306+
'toNode',
307+
edge.target,
308+
);
309+
this.redisService.setField(
310+
`edge:${edge.source}-${edge.target}`,
311+
'type',
312+
'add',
313+
);
314+
}
315+
if (change.action === 'delete') {
316+
// 엣지가 존재하면 삭제
317+
this.redisService.setField(
318+
`edge:${edge.source}-${edge.target}`,
319+
'fromNode',
320+
edge.source,
321+
);
322+
this.redisService.setField(
323+
`edge:${edge.source}-${edge.target}`,
324+
'toNode',
325+
edge.target,
326+
);
327+
this.redisService.setField(
328+
`edge:${edge.source}-${edge.target}`,
329+
'type',
330+
'delete',
331+
);
332+
}
333+
}
334+
}
335+
336+
private async observeEditor(editorDoc: Y.XmlFragment) {
337+
const document = editorDoc.doc as CustomDoc;
338+
const pageId = parseInt(document.name.split('-')[1]);
339+
340+
this.redisService.setField(
341+
`page:${pageId.toString()}`,
342+
'content',
343+
JSON.stringify(yXmlFragmentToProsemirrorJSON(editorDoc)),
344+
);
345+
return;
346+
}
347+
348+
/**
349+
* editor에서 paragraph 내부 text 노드의 text 값의 빈 문자열을 제거한다.
350+
*text 값이 빈 문자열이면 empty text nodes are not allowed 에러가 발생합니다.
351+
*/
352+
private transformText(doc: any) {
353+
doc.content.forEach((paragraph) => {
354+
if (paragraph.type === 'paragraph' && Array.isArray(paragraph.content)) {
355+
paragraph.content.forEach((textNode) => {
356+
if (textNode.type === 'text' && textNode.text === '') {
357+
textNode.text = ' '; // 빈 문자열을 공백으로 대체
358+
}
359+
});
360+
}
361+
});
362+
}
363+
}

0 commit comments

Comments
 (0)