Skip to content

Commit b2523d2

Browse files
authored
Feat/#116-BK: 에디터 공동 편집 블럭 (#117)
contentEditable p 태그 한개에 CRDT를 적용한 공동 편집 블럭 Co-authored-by: hodun <[email protected]>
1 parent e37b792 commit b2523d2

File tree

14 files changed

+385
-46
lines changed

14 files changed

+385
-46
lines changed

client/src/utils/crdt/index.ts renamed to @wabinar-crdt/index.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,32 @@
11
import LinkedList from './linked-list';
22
import { Identifier, Node } from './node';
33

4-
interface RemoteInsertOperation {
4+
export interface RemoteInsertOperation {
55
prevId: Identifier | null;
66
node: Node;
77
}
88

9-
interface RemoteRemoveOperation {
9+
export interface RemoteDeleteOperation {
1010
targetId: Identifier | null;
1111
clock: number;
1212
}
1313

1414
class CRDT {
15-
clock: number;
16-
client: number;
15+
private clock: number;
16+
private client: number;
1717
private structure: LinkedList;
1818

19-
constructor(initialclock: number, client: number) {
19+
constructor(initialclock: number = 1, client: number = 0) {
2020
this.clock = initialclock;
2121
this.client = client;
2222
this.structure = new LinkedList();
2323
}
2424

25+
setClientId(id: number) {
26+
this.client = id;
27+
Object.setPrototypeOf(this.structure, LinkedList.prototype);
28+
}
29+
2530
generateNode(letter: string) {
2631
const id = this.generateIdentifier();
2732
return new Node(letter, id);
@@ -38,7 +43,7 @@ class CRDT {
3843
return { prevId, node };
3944
}
4045

41-
localDelete(index: number): RemoteRemoveOperation {
46+
localDelete(index: number): RemoteDeleteOperation {
4247
const targetId = this.structure.deleteByIndex(index);
4348

4449
return { targetId, clock: this.clock };
@@ -54,7 +59,7 @@ class CRDT {
5459
return prevIndex;
5560
}
5661

57-
remoteDelete({ targetId, clock }: RemoteRemoveOperation) {
62+
remoteDelete({ targetId, clock }: RemoteDeleteOperation) {
5863
const targetIndex = this.structure.deleteById(targetId);
5964

6065
if (++this.clock < clock) {

client/src/utils/crdt/linked-list.ts renamed to @wabinar-crdt/linked-list.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ type RemoteIdentifier = Identifier | null;
55
type ModifiedIndex = number | null;
66

77
export default class LinkedList {
8-
head?: Node;
8+
private head?: Node;
99

1010
insertByIndex(index: number, node: Node): RemoteIdentifier {
1111
try {
File renamed without changes.

@wabinar-crdt/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "@wabinar/crdt",
3+
"version": "1.0.0",
4+
"description": "CRDT for wabinar",
5+
"license": "MIT"
6+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { useRef, useEffect } from 'react';
2+
import { useParams } from 'react-router-dom';
3+
import io, { Socket } from 'socket.io-client';
4+
import env from 'src/config';
5+
import { useCRDT } from 'src/hooks/useCRDT';
6+
import { useOffset } from 'src/hooks/useOffset';
7+
8+
import style from './style.module.scss';
9+
10+
function Editor() {
11+
const workspace = useParams();
12+
const socket: Socket = io(
13+
`${env.SERVER_PATH}/api/sc-workspace/${workspace.id}`,
14+
);
15+
16+
const {
17+
syncCRDT,
18+
readCRDT,
19+
localInsertCRDT,
20+
localDeleteCRDT,
21+
remoteInsertCRDT,
22+
remoteDeleteCRDT,
23+
} = useCRDT();
24+
25+
const { offsetRef, setOffset, clearOffset, offsetHandlers } = useOffset();
26+
27+
const blockRef = useRef<HTMLParagraphElement>(null);
28+
29+
// 로컬에서 일어나는 작성 - 삽입과 삭제 연산
30+
const onInput: React.FormEventHandler = (e) => {
31+
setOffset();
32+
33+
if (offsetRef.current === null) return;
34+
35+
const event = e.nativeEvent as InputEvent;
36+
37+
if (event.isComposing) return; // 한글 입력 무시
38+
39+
if (event.inputType === 'deleteContentBackward') {
40+
const remoteDeletion = localDeleteCRDT(offsetRef.current);
41+
42+
socket.emit('mom-deletion', remoteDeletion);
43+
return;
44+
}
45+
46+
const letter = event.data as string;
47+
48+
const previousLetterIndex = offsetRef.current - 2;
49+
50+
const remoteInsertion = localInsertCRDT(previousLetterIndex, letter);
51+
52+
socket.emit('mom-insertion', remoteInsertion);
53+
};
54+
55+
// 리모트 연산 수행결과로 innerText 변경 시 커서의 위치 조정
56+
const updateCaretPosition = (updateOffset = 0) => {
57+
if (!blockRef.current || offsetRef.current === null) return;
58+
59+
const selection = window.getSelection();
60+
61+
if (!selection) return;
62+
63+
selection.removeAllRanges();
64+
65+
const range = new Range();
66+
67+
// 우선 블럭의 첫번째 text node로 고정, text node가 없는 경우 clearOffset()
68+
if (!blockRef.current.firstChild) {
69+
clearOffset();
70+
return;
71+
}
72+
73+
// range start와 range end가 같은 경우만 가정
74+
range.setStart(
75+
blockRef.current.firstChild,
76+
offsetRef.current + updateOffset,
77+
);
78+
range.collapse();
79+
selection.addRange(range);
80+
81+
// 변경된 offset 반영
82+
setOffset();
83+
};
84+
85+
// crdt의 초기화와 소켓을 통해 전달받는 리모트 연산 처리
86+
useEffect(() => {
87+
socket.on('mom-initialization', (crdt) => {
88+
syncCRDT(crdt);
89+
90+
if (!blockRef.current) return;
91+
92+
blockRef.current.innerText = readCRDT();
93+
blockRef.current.contentEditable = 'true';
94+
});
95+
96+
socket.on('mom-insertion', (op) => {
97+
const prevIndex = remoteInsertCRDT(op);
98+
99+
if (!blockRef.current) return;
100+
101+
blockRef.current.innerText = readCRDT();
102+
103+
if (prevIndex === null || offsetRef.current === null) return;
104+
105+
updateCaretPosition(Number(prevIndex < offsetRef.current));
106+
});
107+
108+
socket.on('mom-deletion', (op) => {
109+
const targetIndex = remoteDeleteCRDT(op);
110+
111+
if (!blockRef.current) return;
112+
113+
blockRef.current.innerText = readCRDT();
114+
115+
if (targetIndex === null || offsetRef.current === null) return;
116+
117+
updateCaretPosition(-Number(targetIndex <= offsetRef.current));
118+
});
119+
120+
return () => {
121+
socket.removeAllListeners();
122+
socket.disconnect();
123+
};
124+
}, []);
125+
126+
// 한글 입력 핸들링
127+
const onCompositionEnd: React.CompositionEventHandler = (e) => {
128+
const event = e.nativeEvent as CompositionEvent;
129+
130+
// compositionend 이벤트가 공백 문자로 발생하는 경우가 있음
131+
const letters = (event.data as string).split('');
132+
const maxIndex = letters.length - 1;
133+
134+
letters.forEach((letter, idx) => {
135+
if (offsetRef.current === null) return;
136+
137+
const previousLetterIndex = offsetRef.current - 2 - (maxIndex - idx);
138+
139+
const remoteInsertion = localInsertCRDT(previousLetterIndex, letter);
140+
141+
socket.emit('mom-insertion', remoteInsertion);
142+
});
143+
};
144+
145+
// 블럭 한개 가정을 위한 임시 핸들러
146+
const onKeyDown: React.KeyboardEventHandler = (e) => {
147+
if (e.key === 'Enter') {
148+
e.preventDefault();
149+
150+
console.log('새 블럭이 생길거에요 ^^');
151+
}
152+
};
153+
154+
return (
155+
<p
156+
ref={blockRef}
157+
onInput={onInput}
158+
onCompositionEnd={onCompositionEnd}
159+
{...offsetHandlers}
160+
onKeyDown={onKeyDown}
161+
className={style['editor-container']}
162+
suppressContentEditableWarning={true}
163+
>
164+
{readCRDT()}
165+
</p>
166+
);
167+
}
168+
169+
export default Editor;
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
.editor-container {
2+
box-sizing: border-box;
3+
height: calc(100% - 70px);
4+
padding: 20px;
5+
background-color: white;
6+
white-space: pre-wrap;
7+
8+
&:focus {
9+
outline: none;
10+
}
11+
}

client/src/components/Mom/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Editor from '../Editor';
12
import style from './style.module.scss';
23

34
function Mom() {
@@ -36,7 +37,7 @@ function Mom() {
3637
</h1>
3738
<span>{selectedMom.createdAt.toLocaleString()}</span>
3839
</div>
39-
<div className={style['editor-container']}>회의록 내용</div>
40+
<Editor />
4041
</div>
4142
</div>
4243
);

client/src/components/Mom/style.module.scss

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
@import 'styles/color.module.scss';
1+
@import 'styles/color.module';
22

33
.mom-container {
44
display: flex;
@@ -18,12 +18,12 @@
1818

1919
.mom-header {
2020
display: flex;
21+
box-sizing: border-box;
2122
flex-direction: row;
2223
align-items: baseline;
2324
justify-content: space-between;
2425
height: 70px;
2526
padding: 0px 15px 20px 15px;
26-
box-sizing: border-box;
2727
color: $gray-100;
2828

2929
& > h1 {
@@ -35,10 +35,3 @@
3535
}
3636
}
3737
}
38-
39-
.editor-container {
40-
height: calc(100% - 70px);
41-
padding: 20px;
42-
box-sizing: border-box;
43-
background-color: white;
44-
}

client/src/hooks/useCRDT.tsx

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import CRDT, {
2+
RemoteInsertOperation,
3+
RemoteDeleteOperation,
4+
} from '@wabinar/crdt';
5+
import { useRef } from 'react';
6+
import { useUserContext } from 'src/hooks/useUserContext';
7+
8+
enum OPERATION_TYPE {
9+
INSERT,
10+
DELETE,
11+
}
12+
13+
interface RemoteOperation {
14+
type: OPERATION_TYPE;
15+
op: RemoteDeleteOperation | RemoteInsertOperation;
16+
}
17+
18+
export function useCRDT() {
19+
const crdtRef = useRef<CRDT>(new CRDT());
20+
const userContext = useUserContext();
21+
22+
let initialized = false;
23+
const operationSet: RemoteOperation[] = [];
24+
25+
const syncCRDT = (object: unknown) => {
26+
Object.setPrototypeOf(object, CRDT.prototype);
27+
28+
crdtRef.current = object as CRDT;
29+
crdtRef.current.setClientId(userContext.userInfo?.user.id);
30+
31+
initialized = true;
32+
operationSet.forEach(({ type, op }) => {
33+
switch (type) {
34+
case OPERATION_TYPE.INSERT:
35+
remoteInsertCRDT(op as RemoteInsertOperation);
36+
break;
37+
case OPERATION_TYPE.DELETE:
38+
remoteDeleteCRDT(op as RemoteDeleteOperation);
39+
break;
40+
default:
41+
break;
42+
}
43+
});
44+
};
45+
46+
const readCRDT = (): string => {
47+
if (!initialized) return '';
48+
return crdtRef.current.read();
49+
};
50+
51+
const localInsertCRDT = (index: number, letter: string) => {
52+
const remoteInsertion = crdtRef.current.localInsert(index, letter);
53+
54+
return remoteInsertion;
55+
};
56+
57+
const localDeleteCRDT = (index: number) => {
58+
const targetIndex = crdtRef.current.localDelete(index);
59+
60+
return targetIndex;
61+
};
62+
63+
const remoteInsertCRDT = (op: RemoteInsertOperation) => {
64+
if (!initialized) {
65+
operationSet.push({ type: OPERATION_TYPE.INSERT, op });
66+
return null;
67+
}
68+
69+
const prevIndex = crdtRef.current.remoteInsert(op);
70+
71+
return prevIndex;
72+
};
73+
74+
const remoteDeleteCRDT = (op: RemoteDeleteOperation) => {
75+
if (!initialized) {
76+
operationSet.push({ type: OPERATION_TYPE.DELETE, op });
77+
return null;
78+
}
79+
80+
const targetIndex = crdtRef.current.remoteDelete(op);
81+
82+
return targetIndex;
83+
};
84+
85+
return {
86+
syncCRDT,
87+
readCRDT,
88+
localInsertCRDT,
89+
localDeleteCRDT,
90+
remoteInsertCRDT,
91+
remoteDeleteCRDT,
92+
};
93+
}

0 commit comments

Comments
 (0)