Skip to content

Commit 85e63e3

Browse files
committed
feat: Node 정보, page content 초기 데이터 세팅 구현
1 parent 9a05fbe commit 85e63e3

File tree

7 files changed

+526
-10681
lines changed

7 files changed

+526
-10681
lines changed

apps/backend/src/yjs/yjs.class.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import * as Y from 'yjs';
2+
3+
// Y.Doc에는 name 컬럼이 없어서 생성했습니다.
4+
export class CustomDoc extends Y.Doc {
5+
name: string;
6+
7+
constructor(name: string) {
8+
super();
9+
this.name = name;
10+
}
11+
}

apps/backend/src/yjs/yjs.schema.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { Schema } from 'prosemirror-model';
2+
3+
export const novelEditorSchema = new Schema({
4+
nodes: {
5+
doc: { content: 'block+' }, // 문서 루트 노드
6+
7+
paragraph: {
8+
content: 'text*',
9+
group: 'block',
10+
toDOM: () => ['p', 0],
11+
parseDOM: [{ tag: 'p' }],
12+
},
13+
14+
text: { group: 'inline' }, // 텍스트 노드
15+
16+
taskList: {
17+
content: 'taskItem+',
18+
group: 'block',
19+
toDOM: () => ['ul', 0],
20+
parseDOM: [{ tag: 'ul' }],
21+
},
22+
23+
taskItem: {
24+
content: 'paragraph*',
25+
attrs: { checked: { default: false } },
26+
toDOM: (node) => [
27+
'li',
28+
{ class: node.attrs.checked ? 'checked' : '' },
29+
0,
30+
],
31+
parseDOM: [
32+
{
33+
tag: 'li',
34+
getAttrs(dom) {
35+
return { checked: dom.classList.contains('checked') };
36+
},
37+
},
38+
],
39+
},
40+
41+
heading: {
42+
attrs: { level: { default: 1 } },
43+
content: 'text*',
44+
group: 'block',
45+
toDOM: (node) => [`h${node.attrs.level}`, 0],
46+
parseDOM: [
47+
{ tag: 'h1', attrs: { level: 1 } },
48+
{ tag: 'h2', attrs: { level: 2 } },
49+
{ tag: 'h3', attrs: { level: 3 } },
50+
],
51+
},
52+
53+
bulletList: {
54+
content: 'listItem+',
55+
group: 'block',
56+
attrs: { tight: { default: false } },
57+
toDOM: () => ['ul', 0],
58+
parseDOM: [{ tag: 'ul' }],
59+
},
60+
61+
orderedList: {
62+
content: 'listItem+',
63+
group: 'block',
64+
attrs: { tight: { default: false }, start: { default: 1 } },
65+
toDOM: (node) => ['ol', { start: node.attrs.start }, 0],
66+
parseDOM: [{ tag: 'ol' }],
67+
},
68+
69+
listItem: {
70+
content: 'paragraph*',
71+
toDOM: () => ['li', 0],
72+
parseDOM: [{ tag: 'li' }],
73+
},
74+
75+
codeBlock: {
76+
content: 'text*',
77+
attrs: { language: { default: null } },
78+
toDOM: (node) => ['pre', ['code', { class: node.attrs.language }, 0]],
79+
parseDOM: [
80+
{
81+
tag: 'pre',
82+
getAttrs(dom) {
83+
return { language: dom.getAttribute('class') };
84+
},
85+
},
86+
],
87+
},
88+
89+
blockquote: {
90+
content: 'paragraph+',
91+
group: 'block',
92+
toDOM: () => ['blockquote', 0],
93+
parseDOM: [{ tag: 'blockquote' }],
94+
},
95+
96+
youtube: {
97+
attrs: {
98+
src: {},
99+
start: { default: 0 },
100+
width: { default: 640 },
101+
height: { default: 480 },
102+
},
103+
toDOM: (node) => [
104+
'iframe',
105+
{
106+
src: node.attrs.src,
107+
width: node.attrs.width,
108+
height: node.attrs.height,
109+
frameborder: '0',
110+
allow:
111+
'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; web-share',
112+
allowfullscreen: true,
113+
},
114+
],
115+
parseDOM: [
116+
{
117+
tag: 'iframe',
118+
getAttrs(dom) {
119+
return {
120+
src: dom.getAttribute('src'),
121+
width: dom.getAttribute('width'),
122+
height: dom.getAttribute('height'),
123+
};
124+
},
125+
},
126+
],
127+
},
128+
129+
twitter: {
130+
attrs: {
131+
src: {},
132+
},
133+
toDOM: (node) => [
134+
'blockquote',
135+
{ class: 'twitter-tweet' },
136+
['a', { href: node.attrs.src }, 0],
137+
],
138+
parseDOM: [
139+
{
140+
tag: 'blockquote.twitter-tweet',
141+
getAttrs(dom) {
142+
return { src: dom.querySelector('a')?.getAttribute('href') };
143+
},
144+
},
145+
],
146+
},
147+
},
148+
149+
marks: {},
150+
});

apps/backend/src/yjs/yjs.service.ts

Lines changed: 100 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,31 +12,19 @@ import * as Y from 'yjs';
1212
import { NodeService } from '../node/node.service';
1313
import { PageService } from '../page/page.service';
1414
import { NodeCacheService } from '../node-cache/node-cache.service';
15-
import { yXmlFragmentToProsemirrorJSON } from 'y-prosemirror';
15+
import {
16+
yXmlFragmentToProsemirrorJSON,
17+
prosemirrorJSONToYXmlFragment,
18+
prosemirrorJSONToYDoc,
19+
yDocToProsemirrorJSON,
20+
} from 'y-prosemirror';
21+
import { novelEditorSchema } from './yjs.schema';
22+
import { Schema } from 'prosemirror-model';
1623
import { EdgeService } from '../edge/edge.service';
17-
// yMap에 저장되는 Node 형태
18-
type YMapNode = {
19-
id: string; // 노드 아이디
20-
type: string; // 노드의 유형
21-
data: {
22-
title: string; // 제목
23-
id: number; // 페이지 아이디
24-
};
25-
position: {
26-
x: number; // X 좌표
27-
y: number; // Y 좌표
28-
};
29-
selected: boolean;
30-
};
31-
32-
// yMap에 저장되는 edge 형태
33-
type YMapEdge = {
34-
id: string; // Edge 아이디
35-
source: string; // 출발 노드 아이디
36-
target: string; // 도착 노드 아이디
37-
sourceHandle: string;
38-
targetHandle: string;
39-
};
24+
import { Node } from 'src/node/node.entity';
25+
import { Edge } from 'src/edge/edge.entity';
26+
import { YMapEdge } from './yjs.type';
27+
import { YMapNode } from './yjs.type';
4028

4129
// Y.Doc에는 name 컬럼이 없어서 생성했습니다.
4230
class CustomDoc extends Y.Doc {
@@ -47,6 +35,7 @@ class CustomDoc extends Y.Doc {
4735
this.name = name;
4836
}
4937
}
38+
5039
@WebSocketGateway(1234)
5140
export class YjsService
5241
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
@@ -62,7 +51,27 @@ export class YjsService
6251
) {}
6352
@WebSocketServer()
6453
server: Server;
54+
insertProseMirrorDataToXmlFragment(xmlFragment: Y.XmlFragment, data: any[]) {
55+
// XML Fragment 초기화
56+
xmlFragment.delete(0, xmlFragment.length);
57+
58+
// 데이터를 순회하면서 추가
59+
data.forEach((nodeData) => {
60+
const yNode = new Y.XmlElement(nodeData.type);
61+
62+
if (nodeData.content) {
63+
nodeData.content.forEach((child) => {
64+
if (child.type === 'text') {
65+
const yText = new Y.XmlText();
66+
yText.insert(0, child.text);
67+
yNode.push([yText]);
68+
}
69+
});
70+
}
6571

72+
xmlFragment.push([yNode]);
73+
});
74+
}
6675
afterInit() {
6776
if (!this.server) {
6877
this.logger.error('서버 초기화 안됨..!');
@@ -75,23 +84,47 @@ export class YjsService
7584

7685
this.ysocketio.initialize();
7786

78-
this.ysocketio.on('document-loaded', (doc: Y.Doc) => {
79-
const nodes = doc.getMap('nodes');
80-
const edges = doc.getMap('edges');
87+
this.ysocketio.on('document-loaded', async (doc: Y.Doc) => {
88+
// Y.Doc에 name이 없어서 새로 만든 CustomDoc
8189
const editorDoc = doc.getXmlFragment('default');
90+
const customDoc = editorDoc.doc as CustomDoc;
8291

83-
// page content의 변경 사항을 감지한다.
84-
editorDoc.observeDeep(() => {
85-
const document = editorDoc.doc as CustomDoc;
86-
const pageId = parseInt(document.name.split('-')[1]);
87-
this.pageService.updatePage(
88-
pageId,
89-
JSON.parse(JSON.stringify(yXmlFragmentToProsemirrorJSON(editorDoc))),
90-
);
91-
});
92+
// document name이 flow-room이라면 모든 노드들을 볼 수 있는 화면입니다.
93+
// 노드를 클릭해 페이지를 열었을 때만 해당 페이지 값을 가져와서 초기 데이터로 세팅해줍니다.
94+
if (customDoc.name !== 'flow-room') {
95+
const pageId = parseInt(customDoc.name.split('-')[1]);
96+
const content = await this.pageService.findPageById(pageId);
97+
98+
// content가 비어있다면 내부 구조가 novel editor schema를 따르지 않기 때문에 오류가 납니다.
99+
// type이라는 key가 있을 때만 초기 데이터를 세팅해줍니다.
100+
'type' in content &&
101+
this.initializePageContent(content.content, editorDoc);
102+
103+
// 페이지 내용 변경 사항을 감지해서 데이터베이스에 갱신합니다.
104+
editorDoc.observeDeep(() => {
105+
const document = editorDoc.doc as CustomDoc;
106+
const pageId = parseInt(document.name.split('-')[1]);
107+
this.pageService.updatePage(
108+
pageId,
109+
JSON.parse(
110+
JSON.stringify(yXmlFragmentToProsemirrorJSON(editorDoc)),
111+
),
112+
);
113+
});
114+
return;
115+
}
116+
117+
// 만약 페이지가 아닌 모든 노드들을 볼 수 있는 document라면 node, edge 초기 데이터를 세팅해줍니다.
118+
// node, edge, page content 가져오기
119+
const nodes = await this.nodeService.findNodes();
120+
const edges = await this.edgeService.findEdges();
121+
const nodesMap = doc.getMap('nodes');
122+
const edgesMap = doc.getMap('edges');
123+
124+
this.initializeYNodeMap(nodes, nodesMap);
92125

93126
// node의 변경 사항을 감지한다.
94-
nodes.observe(() => {
127+
nodesMap.observe(() => {
95128
const nodes = Object.values(doc.getMap('nodes').toJSON());
96129

97130
// 모든 노드에 대해 검사한다.
@@ -115,9 +148,10 @@ export class YjsService
115148
});
116149
});
117150
// edge의 변경 사항을 감지한다.
118-
edges.observe(() => {
151+
edgesMap.observe(() => {
119152
const edges = Object.values(doc.getMap('edges').toJSON());
120153
edges.forEach(async (edge: YMapEdge) => {
154+
console.log(edge);
121155
console.log(edge);
122156
const findEdge = await this.edgeService.findEdgeByFromNodeAndToNode(
123157
parseInt(edge.source),
@@ -135,6 +169,34 @@ export class YjsService
135169
});
136170
}
137171

172+
// YMap에 노드 정보를 넣어준다.
173+
initializeYNodeMap(nodes: Node[], yMap: Y.Map<Object>): void {
174+
nodes.forEach((node) => {
175+
console.log(node);
176+
const nodeId = node.id.toString(); // id를 string으로 변환
177+
178+
// Y.Map에 데이터를 삽입
179+
yMap.set(nodeId, {
180+
id: nodeId,
181+
type: 'note',
182+
data: {
183+
title: node.page.title,
184+
id: node.page.id,
185+
},
186+
position: {
187+
x: node.x,
188+
y: node.y,
189+
},
190+
selected: false, // 기본적으로 선택되지 않음
191+
});
192+
});
193+
}
194+
195+
196+
// yXmlFragment에 content를 넣어준다.
197+
initializePageContent(content: JSON, yXmlFragment: Y.XmlFragment) {
198+
prosemirrorJSONToYXmlFragment(novelEditorSchema, content, yXmlFragment);
199+
}
138200
handleConnection() {
139201
this.logger.log('접속');
140202
}

apps/backend/src/yjs/yjs.type.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// yMap에 저장되는 Node 형태
2+
export type YMapNode = {
3+
id: string; // 노드 아이디
4+
type: string; // 노드의 유형
5+
data: {
6+
title: string; // 제목
7+
id: number; // 페이지 아이디
8+
};
9+
position: {
10+
x: number; // X 좌표
11+
y: number; // Y 좌표
12+
};
13+
selected: boolean;
14+
};
15+
16+
// yMap에 저장되는 edge 형태
17+
export type YMapEdge = {
18+
id: string; // Edge 아이디
19+
source: string; // 출발 노드 아이디
20+
target: string; // 도착 노드 아이디
21+
sourceHandle: string;
22+
targetHandle: string;
23+
};

0 commit comments

Comments
 (0)