기존에 만든 noctaCRDT라이브러리는 localInsert나 사용해야하는 메서드에 따라 그 함수 내부가 아닌 외부에서 설정을 바꿔야하는 경우가 많았습니다.
이를 방지하기 위해 noctaCRDT자체적으로 캐럿을 자동으로 관리할 수 있도록 라이브러리화를 좀더 체계적으로 진행하고 자 했습니다.

현재는 Editor내에 CRDT를 생성하고 중첩적인 구조로 각 블록별, 문자별로 클래스 덩어리를 만들며 해당하는 연산이 일어날때마다 직접 그 블럭과 텍스트에 입력 처리를 해주는 연산이 일어나고 있습니다.
nocta-doc/
├─ package.json
├─ tsconfig.json
└─ src/
├─ core/ # CRDT 핵심 로직
│ ├─ NoctaDoc.ts # public 진입점
│ ├─ OperationLog.ts # encode/update, stateVector
│ ├─ LinkedList.ts # 공용 연결리스트
│ └─ Id.ts # Lamport·Client ID
│
├─ model/ # 도메인 모델
│ ├─ Block.ts
│ ├─ Char.ts
│ └─ Types.ts
│
├─ ops/ # 직렬화 대상 Operation 정의
│ ├─ InsertOp.ts
│ ├─ DeleteOp.ts
│ └─ index.ts
│
├─ codec/ # VarInt·CBOR 등 인코딩 헬퍼
│ ├─ Encoder.ts
│ └─ Decoder.ts
│
├─ util/ # assert, helper, iterator
│ └─ invariant.ts
│
└─ index.ts # `export *` 모음
→ 위 구조는 외부 라이브러리 사용성을 극대화한 구조임.
→ 좀더 React나 실제 라이브러리에서 쓰기 쉬운 형태로 구조화와 분리가 필요함

nocta-doc/
├── core/ # CRDT 핵심 클래스들
│ ├── NoctaDoc.ts # 중앙 API, Editor는 이것만 알면 됨
│ ├── Adapter.ts # Editor와 NoctaDoc을 연결하는 중간계층
│ ├── CaretManager.ts # 캐럿 위치 상태 추적 및 변경
│ └── StateVector.ts # 버전벡터 관리 (CRDT 충돌 해결)
├── crdt/ # CRDT 내부 자료구조
│ ├── BlockCRDT.ts # 블록 수준 CRDT (e.g. reorder, checkbox toggle)
│ ├── CharCRDT.ts # 문자 수준 CRDT (e.g. insert, delete)
│ ├── LinkedList.ts # Char/Block용 공통 연결 리스트
│ ├── Node.ts # CharNode, BlockNode 등
│ └── Id.ts # Lamport clock + clientId 구조
├── model/ # 도메인 모델
│ ├── Block.ts # Block 모델 정의
│ ├── Char.ts # Char 모델 정의
│ └── Types.ts # 공통 인터페이스, enum 등
├── sync/ # 네트워크 동기화 관련
│ ├── Encoder.ts # encodeUpdate
│ ├── Decoder.ts # applyUpdate
│ └── OperationLog.ts # insert, delete 등의 로그 저장
├── store/ # 내부 상태 저장소
│ ├── EditorState.ts # 전체 문서 상태
│ ├── BlockState.ts # Block들의 Map
│ └── CharState.ts # Char들의 Map
├── utils/ # 유틸 함수들
│ ├── PositionUtils.ts # caret offset 계산 등
│ └── MergeUtils.ts # CRDT 병합 함수
└── index.ts # NoctaDoc의 진입점 (Adapter + Doc)
import { NoctaDoc } from "noctaDoc";
const doc = new NoctaDoc(clientId);
doc.insertChar(blockId, index, "안녕");
doc.deleteBlock(blockId);
// uuid + clock

ClientNoctaRealm, ServerNoctaRealm 으로 분기된다.
uuid 를 부여받는다 고려하여 string을 쓰고있다.| 측면 | Block 내부 CharCRDT | CharCRDT에서 blockId 분기 |
|---|---|---|
| 구성 | 블록 독립적 상태 보유 | 중앙 집중적 상태 |
| 확장성 | rich block 관리에 유리 | 전체 구조가 단순 |
| 렌더링 | 블록 단위 리렌더링 쉬움 | 효율적이지만 분기 처리 필요 |
| 상태 동기화 | 블록 단위 직렬화 가능 | 전체 상태 직렬화 편함 |
| 중첩 깊이 | 깊음 | 얕음 |
만약에 block의 속성을 바꾼다면?
특정 글자를 드래그해서 리치텍스트를 적용한다면, CharNode?
blockCRDT - block(3번째)
charCRDT - char(3번쨰 블록의 2번째 글)
head: “a”-”b”-”c”(2번째블록), “e”-”f” (3번쨰블록)
실제 y.doc은 어떻게 쓰나?
Y.Doc
└── blocks (Y.Array)
├─ Block 0 (Y.Map)
│ ├─ type: "paragraph"
│ └─ chars: Y.Text ("Hello world")
├─ Block 1 (Y.Map)
│ └─ ...
Y.Doc
└── blocks (Y.Array)
├─ Block 0 (Y.Map)
│ Y.Text ("Hello world","현훈","주호")
얘네들은 이름을 따로 나눠놨다.
Y.Array
문서의 block들을 저장하는 리스트
Y.Map
각 block을 표현. type, 속성 등 보관
Y.Text
실제 텍스트 데이터 (CRDT 문자 삽입/삭제 전용)
그대로 중첩 구조로 가지만, linkedlist등의 불필요한 클래스는 제거 한다.
NoctaDoc
block을 제거하면 CharCRDT에서도 해당 blockId 다 날려야 해서 묶여 있음
tombstone처리로 인해 큰 문제가 안될 수도 있다.물론 우리도 한줄로 쭉 작성하고, block을 그냥 구분자 느낌으로 줄 수 있지만, 기존 구조가 이미 block 단위 리치 편집기로 쓰고있기 때문에 중첩구조는 그대로 가져가기로 하였다.
export interface InsertOperation {
type: "insert";
node: Block | Char;
}
editor.addEventListener("selectionchange", () => {
const caret = getCaretPosition();
nocta.realm.setCaret(blockId, charId); // ← 커서 위치 업데이트
});
setCaret(blockId: string, charId: string) {
this.socket.emit("caret", { blockId, charId });
}
this.socket.on("caret", (payload) => {
// 저장하거나, 그대로 브로드캐스트
this.socket.broadcast.emit("caret", { clientId, ...payload });
});
this.socket.on("caret", ({ clientId, blockId, charId }) => {
// 해당 유저의 캐럿을 화면에 표시
renderRemoteCaret(clientId, blockId, charId);
});
import { Nocta } from "@noctaDoc";
import { useRef, useEffect } from "react";
const socket = {
on: () => {},
emit: () => {},
};
const client = Nocta.createClient({ socket, clientId: "client-123" });
export const TestEditor = () => {
const editorRef = useRef<HTMLDivElement>(null);
const prevText = useRef("");
useEffect(() => {
// 처음 렌더링 시 block-1 생성
client.insertBlock(null, "block-1", "paragraph");
}, []);
const handleInput = () => {
const blockId = "block-1";
const newText = editorRef.current?.innerText || "";
const oldText = prevText.current;
if (newText.length > oldText.length) {
// 입력 발생
const addedChar = newText.slice(oldText.length); // 단일 문자만 가정
client.insertChar(blockId, oldText.length, addedChar);
} else if (newText.length < oldText.length) {
// 삭제 발생
client.deleteChar(blockId, oldText.length - 1);
}
console.log(client.getText("block-1"));
prevText.current = newText;
};
return (
<div>
<h2>TestEditor</h2>
<div
ref={editorRef}
contentEditable
onInput={handleInput}
style={{
border: "1px solid black",
padding: "8px",
minHeight: "100px",
fontSize: "16px",
}}
/>
</div>
);
};

간단한 동작 테스트 성공!
Github PR:
https://github.com/boostcampwm-2024/refactor-web33-Nocta/pull/48