Skip to content

Commit 0871a0c

Browse files
dohun31se030
andauthored
Feat/#241-BK: 모달에서 블럭 타입 선택 기능 (#248)
Feat/#241-BK: 서버 Block 모델 수정 (#248) * feat: Block 스키마 변경 Co-authored-by: Seyoung Kim <[email protected]> * chore: Option, Vote 인터페이스 export * fix: putBlock 사용처에 인자 추가 * refactor: BlockType 공통 패키지 정의 사용 * fix: switch 분기문에 BlockType 반영 * fix: package-lock.json 업데이트 * fix: type, import 이슈 해결 * Feat/#249-BK: 블록 타입 변경 구현 (#251) * feat: load-type 이벤트 추가 Co-authored-by: Seyoung Kim <[email protected]> Resolve: #249 * feat: type별 컴포넌트 분기 Resolve: #249 * refactor: useEffect내부에 선언된 함수 외부에 선언 * refactor: onInput 함수 다른 핸들러랑 가깝게 위치 변경 * Feat/#252-BK: 블럭 첫번째 오프셋에서 / 입력을 통해 타입 선택 (#256) * fix: putBlock 사용처에 인자 추가 * refactor: BlockType 공통 패키지 정의 사용 * fix: switch 분기문에 BlockType 반영 * fix: package-lock.json 업데이트 * fix: type, import 이슈 해결 * feat: / 입력 시 모달 표시 Resolve: #252 * feat: block type onSelect 구현 Resolve: #252 * refactor: createElement 적용 Resolve: #252 * feat: type 선택 이벤트 연동 Resolve: #252 * feat: 블록 타입 추가 * feat: type 변경 시 init 이벤트 전달 * feat: 타입 선택 시 모달 닫히는 핸들러 추가 Resolve: #252 * refactor: 모달 position absolute로 변경 Resolve: #252 * chore: 블록 썸네일, desc 수정 * refactor: update-type 상수화 및 적용 Resolve: #252 * refactor: load-type 상수화 및 적용 Resolve: #252 * refactor: useEffect내부에 선언된 함수 외부에 선언 * refactor: onInput 함수 다른 핸들러랑 가깝게 위치 변경 Co-authored-by: dohun <[email protected]> Co-authored-by: Seyoung Kim <[email protected]> Co-authored-by: Seyoung Kim <[email protected]> Co-authored-by: Seyoung Kim <[email protected]> Co-authored-by: se030 <[email protected]> Co-authored-by: Seyoung Kim <[email protected]>
1 parent 95b0d8b commit 0871a0c

File tree

18 files changed

+427
-227
lines changed

18 files changed

+427
-227
lines changed

@wabinar/api-types/block.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export enum BlockType {
2+
H1,
3+
H2,
4+
H3,
5+
P,
6+
VOTE,
7+
QUESTION,
8+
}

client/src/components/BlockSelector/BlockItem/index.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1-
import style from './style.module.scss';
1+
import { BlockType } from '@wabinar/api-types/block';
22

3+
import style from './style.module.scss';
34
interface BlockItemProps {
5+
id: number;
46
name: string;
57
desc: string;
68
thumbnail: string;
9+
onSelect: (arg: BlockType) => void;
710
}
811

9-
function BlockItem({ name, desc, thumbnail }: BlockItemProps) {
12+
function BlockItem({ id, name, desc, thumbnail, onSelect }: BlockItemProps) {
1013
return (
11-
<li className={style['block-item']}>
14+
<li className={style['block-item']} onClick={() => onSelect(id)}>
1215
<img src={thumbnail} alt={name + '블록'} />
1316

1417
<div className={style.text}>

client/src/components/BlockSelector/index.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
1+
import { BlockType } from '@wabinar/api-types/block';
12
import { BLOCKS_TYPE } from 'src/constants/block';
23

34
import BlockItem from './BlockItem';
45
import style from './style.module.scss';
56

6-
function BlockSelector() {
7+
interface BlockSelectorProps {
8+
onSelect: (arg: BlockType) => void;
9+
}
10+
11+
function BlockSelector({ onSelect }: BlockSelectorProps) {
712
return (
813
<div className={style['block-selector']}>
914
<div className={style['block-displayed']}>
1015
<strong>블록</strong>
1116

1217
<ul className={style['block-list']}>
1318
{BLOCKS_TYPE.map(({ id, name, desc, thumbnail }) => (
14-
<BlockItem key={id} {...{ id, name, desc, thumbnail }} />
19+
<BlockItem key={id} {...{ id, name, desc, thumbnail, onSelect }} />
1520
))}
1621
</ul>
1722
</div>

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
@import 'styles/color.module';
22

33
.block-selector {
4+
position: absolute;
45
height: 100%;
56
overflow: hidden;
67

@@ -11,6 +12,7 @@
1112
max-height: 280px;
1213
padding: 5px;
1314
overflow: auto;
15+
border: 1px solid $gray-300-transparent;
1416
border-radius: 4px;
1517
background-color: $white;
1618
box-shadow: rgb(15 15 15 / 5%) 0px 0px 0px 1px,
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { BlockType } from '@wabinar/api-types/block';
2+
import {
3+
RemoteInsertOperation,
4+
RemoteDeleteOperation,
5+
} from '@wabinar/crdt/linked-list';
6+
import React, { useEffect, useRef, memo, useState } from 'react';
7+
import BlockSelector from 'src/components/BlockSelector';
8+
import SOCKET_MESSAGE from 'src/constants/socket-message';
9+
import { useCRDT } from 'src/hooks/useCRDT';
10+
import { useOffset } from 'src/hooks/useOffset';
11+
import useSocketContext from 'src/hooks/useSocketContext';
12+
13+
import ee from '../EventEmitter';
14+
15+
interface BlockProps {
16+
id: string;
17+
index: number;
18+
onKeyDown: React.KeyboardEventHandler;
19+
type: BlockType;
20+
setType: (arg: BlockType) => void;
21+
}
22+
23+
function TextBlock({ id, index, onKeyDown, type, setType }: BlockProps) {
24+
const { momSocket: socket } = useSocketContext();
25+
const [isOpen, setIsOpen] = useState<boolean>(false);
26+
27+
const {
28+
syncCRDT,
29+
readCRDT,
30+
localInsertCRDT,
31+
localDeleteCRDT,
32+
remoteInsertCRDT,
33+
remoteDeleteCRDT,
34+
} = useCRDT();
35+
36+
const blockRef = useRef<HTMLParagraphElement>(null);
37+
38+
const { offsetRef, setOffset, clearOffset, offsetHandlers } =
39+
useOffset(blockRef);
40+
41+
// 리모트 연산 수행결과로 innerText 변경 시 커서의 위치 조정
42+
const updateCaretPosition = (updateOffset = 0) => {
43+
if (!blockRef.current || offsetRef.current === null) return;
44+
45+
const selection = window.getSelection();
46+
47+
if (!selection) return;
48+
49+
selection.removeAllRanges();
50+
51+
const range = new Range();
52+
53+
// 우선 블럭의 첫번째 text node로 고정, text node가 없는 경우 clearOffset()
54+
if (!blockRef.current.firstChild) {
55+
clearOffset();
56+
return;
57+
}
58+
59+
// range start와 range end가 같은 경우만 가정
60+
range.setStart(
61+
blockRef.current.firstChild,
62+
offsetRef.current + updateOffset,
63+
);
64+
range.collapse();
65+
selection.addRange(range);
66+
67+
// 변경된 offset 반영
68+
setOffset();
69+
};
70+
71+
const onInitialize = (crdt: unknown) => {
72+
syncCRDT(crdt);
73+
74+
if (!blockRef.current) return;
75+
76+
blockRef.current.innerText = readCRDT();
77+
blockRef.current.contentEditable = 'true';
78+
};
79+
80+
const onInsert = (op: RemoteInsertOperation) => {
81+
const prevIndex = remoteInsertCRDT(op);
82+
83+
if (!blockRef.current) return;
84+
85+
blockRef.current.innerText = readCRDT();
86+
87+
if (prevIndex === null || offsetRef.current === null) return;
88+
89+
updateCaretPosition(Number(prevIndex < offsetRef.current));
90+
};
91+
92+
const onDelete = (op: RemoteDeleteOperation) => {
93+
const targetIndex = remoteDeleteCRDT(op);
94+
95+
if (!blockRef.current) return;
96+
97+
blockRef.current.innerText = readCRDT();
98+
99+
if (targetIndex === null || offsetRef.current === null) return;
100+
101+
updateCaretPosition(-Number(targetIndex <= offsetRef.current));
102+
};
103+
104+
// crdt의 초기화와 소켓을 통해 전달받는 리모트 연산 처리
105+
useEffect(() => {
106+
socket.emit(SOCKET_MESSAGE.BLOCK.INIT, id);
107+
108+
ee.on(`${SOCKET_MESSAGE.BLOCK.INIT}-${id}`, onInitialize);
109+
ee.on(`${SOCKET_MESSAGE.BLOCK.UPDATE_TEXT}-${id}`, onInitialize);
110+
ee.on(`${SOCKET_MESSAGE.BLOCK.INSERT_TEXT}-${id}`, onInsert);
111+
ee.on(`${SOCKET_MESSAGE.BLOCK.DELETE_TEXT}-${id}`, onDelete);
112+
113+
return () => {
114+
ee.off(`${SOCKET_MESSAGE.BLOCK.INIT}-${id}`, onInitialize);
115+
ee.off(`${SOCKET_MESSAGE.BLOCK.UPDATE_TEXT}-${id}`, onInitialize);
116+
ee.off(`${SOCKET_MESSAGE.BLOCK.INSERT_TEXT}-${id}`, onInsert);
117+
ee.off(`${SOCKET_MESSAGE.BLOCK.DELETE_TEXT}-${id}`, onDelete);
118+
};
119+
}, []);
120+
121+
// 로컬에서 일어나는 작성 - 삽입과 삭제 연산
122+
const onInput: React.FormEventHandler = (e) => {
123+
setOffset();
124+
125+
if (offsetRef.current === null) return;
126+
127+
const event = e.nativeEvent as InputEvent;
128+
129+
if (event.isComposing) return; // 한글 입력 무시
130+
131+
if (event.inputType === 'deleteContentBackward') {
132+
const remoteDeletion = localDeleteCRDT(offsetRef.current);
133+
socket.emit(SOCKET_MESSAGE.BLOCK.DELETE_TEXT, id, remoteDeletion);
134+
return;
135+
}
136+
137+
const letter = event.data as string;
138+
const previousLetterIndex = offsetRef.current - 2;
139+
const remoteInsertion = localInsertCRDT(previousLetterIndex, letter);
140+
141+
socket.emit(SOCKET_MESSAGE.BLOCK.INSERT_TEXT, id, remoteInsertion);
142+
};
143+
144+
// 한글 입력 핸들링
145+
const onCompositionEnd: React.CompositionEventHandler = (e) => {
146+
const event = e.nativeEvent as CompositionEvent;
147+
148+
// compositionend 이벤트가 공백 문자로 발생하는 경우가 있음
149+
const letters = (event.data as string).split('');
150+
const maxIndex = letters.length - 1;
151+
152+
letters.forEach((letter, idx) => {
153+
if (offsetRef.current === null) return;
154+
155+
const previousLetterIndex = offsetRef.current - 2 - (maxIndex - idx);
156+
157+
const remoteInsertion = localInsertCRDT(previousLetterIndex, letter);
158+
159+
socket.emit(SOCKET_MESSAGE.BLOCK.INSERT_TEXT, id, remoteInsertion);
160+
});
161+
};
162+
163+
const onPaste: React.ClipboardEventHandler<HTMLParagraphElement> = (e) => {
164+
e.preventDefault();
165+
166+
setOffset();
167+
if (offsetRef.current === null || !blockRef.current) return;
168+
169+
let previousLetterIndex = offsetRef.current - 1;
170+
const previousText = blockRef.current.innerText.slice(
171+
0,
172+
previousLetterIndex + 1,
173+
);
174+
const nextText = blockRef.current.innerText.slice(previousLetterIndex + 1);
175+
176+
const pastedText = e.clipboardData.getData('text/plain').replace('\n', '');
177+
const remoteInsertions = pastedText
178+
.split('')
179+
.map((letter) => localInsertCRDT(previousLetterIndex++, letter));
180+
181+
socket.emit(SOCKET_MESSAGE.BLOCK.UPDATE_TEXT, id, remoteInsertions);
182+
183+
blockRef.current.innerText = previousText + pastedText + nextText;
184+
updateCaretPosition(pastedText.length);
185+
};
186+
187+
const onKeyDownComposite: React.KeyboardEventHandler<HTMLParagraphElement> = (
188+
e,
189+
) => {
190+
offsetHandlers.onKeyDown(e);
191+
onKeyDown(e);
192+
};
193+
194+
const commonHandlers = {
195+
onInput,
196+
onCompositionEnd,
197+
...offsetHandlers,
198+
onKeyDown: onKeyDownComposite,
199+
onPaste,
200+
};
201+
202+
const BLOCK_TYPES = Object.values(BlockType)
203+
.filter((el) => typeof el === 'string')
204+
.map((el) => (el as string).toLocaleLowerCase());
205+
206+
const onSelect = (id: BlockType) => {
207+
setType(id);
208+
setIsOpen(false);
209+
};
210+
211+
return (
212+
<>
213+
{React.createElement(
214+
BLOCK_TYPES[type],
215+
{
216+
ref: blockRef,
217+
'data-id': id,
218+
'date-index': index,
219+
...commonHandlers,
220+
suppressContentEditableWarning: true,
221+
},
222+
readCRDT(),
223+
)}
224+
{isOpen && <BlockSelector onSelect={onSelect} />}
225+
</>
226+
);
227+
}
228+
229+
export default memo(TextBlock);

0 commit comments

Comments
 (0)