해당 문제는 innerHtml을 만들때 문제가 발생했습니다.
const sanitizeText = (text: string): string => {
return text
.replace(/<br>/g, " ")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
return text.replace(/<br>/g, "\\u00A0");
기존에는 특수문자들을 html 엔티티로 변경해주었습니다. 이렇게 할 경우 다음과 같은 문제가 발생합니다
// 사용자가 따옴표 뒤에 캐럿을 위치시키면:
'Hello "| world' // | 는 캐럿 위치
// 실제 DOM에서는 이렇게 변환되어 있음:
'Hello "| world' // 의도한 위치보다 5글자 더 뒤로 밀림
스페이스 뿐만이 아니라 위의 정규표현식에 매치되는 모든 특수문자들을 html엔티티로 변경하다보니 드래그해서 선택한 selection과 실제 텍스트 사이에 불일치가 발생했습니다.
const sanitizeText = (text: string): string => {
return text.replace(/<br>/g, "\\u00A0");
};
특수문자의 경우 그대로 입력하고, 스페이스의 경우 \\u00A0
으로 처리했습니다.
해당 문제는 handleKeyDown에서 백스페이스 처리만 하고 나머지 입력에 대해 처리하지 않아서 발생한 문제입니다. 드래그한 후 키입력이 들어오면 브라우저 기본동작에 따라 브라우저의 텍스트는 삭제되고 키가 입력되지만, 실제 Char 인스턴스는 삭제되지 않아 다시 키를 입력하면 setInnerHtml
함수에서 지워지지 않은 Char 노드들을 다시 렌더링하면서 발생한 문제입니다.
const deleteSelectedText = useCallback(
(block: Block, startOffset: number, endOffset: number) => {
for (let i = endOffset - 1; i >= startOffset; i--) {
const operationNode = block.crdt.localDelete(i, block.id, pageId);
sendCharDeleteOperation(operationNode);
}
block.crdt.currentCaret = startOffset;
},
[pageId, sendCharDeleteOperation],
);
deleteSelectedText
함수를 통해 삭제 연산을 실행합니다.
const handleKeyWithSelection = useCallback(
(
e: React.KeyboardEvent<HTMLDivElement>,
block: Block,
startOffset: number,
endOffset: number,
) => {
switch (e.key) {
case "Backspace":
case "Delete": {
e.preventDefault();
deleteSelectedText(block, startOffset, endOffset);
setEditorState({
clock: editorCRDT.clock,
linkedList: editorCRDT.LinkedList,
});
break;
}
// 복사, 잘라내기, 실행취소 등 조합 키는 기본 동작 허용
case "c":
case "v":
case "x":
case "z":
case "y": {
if (e.metaKey || e.ctrlKey) {
// 기본 브라우저 동작 허용
return;
}
deleteSelectedText(block, startOffset, endOffset);
onKeyDown(e);
break;
}
// 탐색 및 선택 관련 키
case "Tab":
case "ArrowLeft":
case "ArrowRight":
case "ArrowUp":
case "ArrowDown":
case "Home":
case "End":
case "PageUp":
case "PageDown": {
e.preventDefault();
onKeyDown(e);
break;
}
// 기능 키들은 기본 동작 허용
case "F1":
case "F2":
case "F3":
case "F4":
case "F5":
case "F6":
case "F7":
case "F8":
case "F9":
case "F10":
case "F11":
case "F12": {
return;
}
case "Enter": {
deleteSelectedText(block, startOffset, endOffset);
onKeyDown(e);
break;
}
case "Escape": {
// 선택 해제만 하고 다른 동작은 하지 않음
window.getSelection()?.removeAllRanges();
return;
}
default: {
// 일반 입력 키의 경우
if (e.metaKey || e.ctrlKey || e.altKey) {
// 다른 단축키들 허용
return;
}
deleteSelectedText(block, startOffset, endOffset);
onKeyDown(e);
}
}
},
[deleteSelectedText, editorCRDT, onKeyDown],
);
handleKeyWithSelection
함수를 통해 모든 키입력에 대한 처리를 실행합니다. 각 키입력에 따른 처리를 하고 필요한 경우 onKeyDown(useMarkdownGrammer 커스텀 훅)을 실행합니다.
이 부분에서 추가적인 문제가 발생했습니다.
기존 useMarkdownGrammer의 경우 엔터
, 백스페이스
, 스페이스
, 탭
, 화살표
입력만 처리했습니다. 그러나 위의 5개 입력 이외에도 브라우저 동작 외에 직접 처리해줘야 하는 경우가 존재했습니다.
delete
: 기본 동작은 캐럿 오른쪽의 텍스트를 삭제하는 입력입니다. 하지만 해당 로직에 대해 서버로 연산을 전송하거나 Char 인스턴스를 삭제하는 로직이 존재하지 않았습니다