이전 게시글에서 문제가 있어서 Yjs를 도입하기로 했다.
이 글을 통해 어떻게 도입했는지 작성해보려고한다.
Yjs알아보기
공유 타입 선택하기
Yjs는 다양한 타입을 지원한다.
map도있고, array도 있다. 하지만, 우리는 코드 에디터니까 text를 선택했다.
이렇게 사용할 수 있다.
insert를 통해 n번 인덱스에 문자를 삽입하는 형태다.
toString을 통해 실제 텍스트를 얻어올 수 있다.
delete역시 동일한데, insert와 다르게 length를 두번째 인자로 받는다.
어떻게 병합하지
여기서 핵심만 보자면
// Merge changes from remote
const update = Y.encodeStateAsUpdate(ydocRemote) //update용 객체(Uint8Array다)
Y.applyUpdate(ydoc, update)
여기가 중요하다.
remote로 받은 YDoc를 encodeStateAsUpdate를 applyUpdate함수에 두번째인자로 넘겨주면된다.
커서복구하기
createRelativePositionFromTypeIndex를 통해 ytext에서의 상대적 커서위치를 가져올 수 있고,
createAboslutePositionFromRelativePosition을 통해 상대적 커서위치를 인자로 받아 현재의 그 글자 위치를 찾아낼 수 있다.
그렇다면 이걸 활용해서 메시지를 받는 경우, 커서를 복구시키는 로직을 짜보자.
- y.doc을 RTC DataChannel로 수신받는다.
- 현재 자신의 ydoc에서 상대 커서위치를 저장한다.
- 받은 y.doc을 자신의 ydoc과 병합시킨다.
- 저장해둔 상대 커서위치를 통해 업데이트된 ydoc에서의 커서위치를 찾아온다.
- 커서위치를 복구시킨다.
구현된 RTC에 적용하기
원래 ydoc은 커넥션에 관한 부분도 도와주나, 이부분은 우리가 이미 webRTC로 커넥션을 구현해놓았기에 이미 적용된 RTC에 Ydocs를 도입시켜야 했다.
props drilling을 막기위해 YDoc을 context API로 주입시켜주었다.
사실 이건 이후에 교체를 위해서 이런식으로 구성했다.
교체할 계획이 없고 라이브러리를 그대로 사용할 것이라면 yjs객체를 전역객체로 두고 import해서 써도 된다.
import React from 'react';
import * as Y from 'yjs';
const crdt = new Y.Doc();
export const CRDTContext = React.createContext<Y.Doc>(crdt);
interface CRDTProviderProps {
children: React.ReactNode;
}
export function CRDTProvider({ children }: CRDTProviderProps) {
return <CRDTContext.Provider value={crdt}>{children}</CRDTContext.Provider>;
}
먼저 필요한 state를 구성해보자.
export default function EditorSection({ defaultCode }: EditorSectionProps) {
const [plainCode, setPlainCode] = useState<string>(''); //입력된 코드 state
const [cursorPosition, setCursorPosition] = useState<number>(0); //커서 위치 state
const { codeDataChannel, languageDataChannel } = useDataChannels(); //RTC DataChannel을 가져옴
const crdt = useContext(CRDTContext); //주입시킨 crdt객체를 가져옴
...
}
textarea onChange이벤트
이제 textArea가 change되는경우, 어떻게 처리해줄지 구성해주자.
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = event.target.value;
const newCursor = event.target.selectionStart; // 연산 이후의 최종 위치
setPlainCode(newText); //코드 상태 업데이트
setCursorPosition(newCursor); //커서 업데이트
const changedLength = plainCode.length - newText.length; //기존의 코드상태와 새로 입력한 코드 길이 비교
const isAdded = changedLength < 0; //추가 입력했는가
if (isAdded) {//추가 입력한경우
const addedText = newText.slice(newCursor - Math.abs(changedLength), newCursor);
crdt.getText('sharedText').insert(newCursor - Math.abs(changedLength), addedText);
} else { //삭제한경우
const removedLength = Math.abs(changedLength);
crdt.getText('sharedText').delete(newCursor, removedLength);
}
sendMessageDataChannels(codeDataChannel, Y.encodeStateAsUpdate(crdt)); //webRTC커넥션 datachannel로 전송
};
RTC DataChannel을 통해 코드를 받았을때
const handleRecieveCodeMessage = (event: MessageEvent) => {
//상대적 위치를 가져옴
const relPos = Y.createRelativePositionFromTypeIndex(crdt.getText('sharedText'), cursorPosition);
const update = new Uint8Array(event.data); //받은 객체를 UintArray로 변환하여 update할 수 있도록 만듦
Y.applyUpdate(crdt, update); // 자신의 객체에 업데이트
const updatedText = crdt.getText('sharedText').toString();
setPlainCode(updatedText); //에디터 코드 업데이트
const pos = Y.createAbsolutePositionFromRelativePosition(relPos, crdt); //업데이트 된 요소에서 커서 위치
if (pos) setCursorPosition(pos.index); //커서위치 복구
};
한글문제
이렇게 하면, 영어는 잘 작동할 것이다.
하지만, 한글의 경우 제대로 전송이 안되고, 깨지는 문제가 발생한다.
이는 한글이 한글자마다, 자모음이 합쳐지지않은 상태로 전송되기때문이다.
CompositionEnd
https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionend_event
CompositionEnd는 IME입력이 완료된 시점에 발생하는 이벤트다.
IME는 입력 방식 편집기(Input method editor)로, 한글, 한자처럼 자판보다 많은 글자를 계산하거나 조합하여 입력해주는 소프트웨어다.
이 IME가 조합을 완료한 시점에 발생하는 이벤트인 것이다.
이 이벤트에 대한 핸들러를 등록해 한글일때는 조합을 완료한 후에 보내주도록 처리해주면된다.
onCompositionEnd를 달아주었다.
compositionEnd 이벤트 핸들러 작성하기
한글을 한글자입력하는경우에만 처리해주면되므로,(두글자부터는 붙여넣기라서 onChange에서 처리해줄 수 있다.) 아래와 같이 작성해주었다.
const handleCompositionEnd = (event: React.CompositionEvent<HTMLTextAreaElement>) => {
crdt.getText('sharedText').insert(cursorPosition - 1, event.data);
sendMessageDataChannels(codeDataChannel, Y.encodeStateAsUpdate(crdt));
};
그러나 이것만 해주면, compositionEnd일때 한번, onChange에서 두번 P2P통신이 일어나므로 결국은 글자가 깨지게된다.
onChange이벤트에서 한글인 경우를 처리해주자.
onChange이벤트 한글 막기
한글 정규식을 사용해서 한글자를 입력하는 경우에는 insert시키지 않도록 처리했다.
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
...
if (isAdded) {
const addedText = newText.slice(newCursor - Math.abs(changedLength), newCursor);
const isOneLetter = addedText.length === 1;
const isKorean = addedText.match(/[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/); //한글 필터링
if (isOneLetter && isKorean) return; //한글이고, 한글자면 insert하지 않는다.
crdt.getText('sharedText').insert(newCursor - Math.abs(changedLength), addedText);
} else { //삭제인경우는 한글을 고려할 필요가 없다.
...
}
sendMessageDataChannels(codeDataChannel, Y.encodeStateAsUpdate(crdt));
};
이렇게 하면 P2P기반의 CRDT가 적용된다.
하지만, 현재 코드는 구현부에 모두 Yjs를 쓰고있다.
나는 이후에(언젠가는...) crdt를 직접 구현해볼 계획이 있기때문에, 코드 구현부에 대한 Yjs의존성을 낮추고, Yjs를 언제든지 교체할 수 있게 만들어 둘것이다.