Skip to content

Refactor/#047 에디터바인딩 한글컴포징 추가 #51

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: refactor/noctaDoc
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 79 additions & 21 deletions client/src/TestEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Nocta } from "@noctaDoc";
import { useRef, useEffect } from "react";
import { useRef, useEffect, useState } from "react";

const socket = {
on: () => {},
Expand All @@ -10,43 +10,101 @@ const client = Nocta.createClient({ socket, clientId: "client-123" });

export const TestEditor = () => {
const editorRef = useRef<HTMLDivElement>(null);
const prevText = useRef("");
const [binding, setBinding] = useState<any>(null);
const [isReady, setIsReady] = useState(false);
const [crdtText, setCrdtText] = useState("");
const [domText, setDomText] = useState("");

// CRDT 텍스트를 주기적으로 업데이트
useEffect(() => {
// 처음 렌더링 시 block-1 생성
client.insertBlock(null, "block-1", "paragraph");
const interval = setInterval(() => {
const newCrdtText = client.getTextSafe("block-1");
const newDomText = editorRef.current?.innerText || "";

setCrdtText(newCrdtText);
setDomText(newDomText);
}, 100); // 100ms마다 업데이트

return () => clearInterval(interval);
}, []);

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);
useEffect(() => {
if (editorRef.current) {
console.log("🚀 에디터 바인딩 시작...");

// 에디터 바인딩 생성 - 이제 모든 게 자동화됨!
const editorBinding = client.bindToEditor(editorRef.current, "block-1");
setBinding(editorBinding);
setIsReady(true);

console.log("✅ 에디터 바인딩 완료!");

return () => {
console.log("🧹 에디터 바인딩 정리 중...");
// 컴포넌트 언마운트 시 바인딩 해제
editorBinding.destroy();
};
}
console.log(client.getText("block-1"));
prevText.current = newText;
};
}, []);

return (
<div>
<h2>TestEditor</h2>
<h2>TestEditor - 바인딩 시스템 적용 🚀</h2>
<div
ref={editorRef}
contentEditable
onInput={handleInput}
style={{
border: "1px solid black",
padding: "8px",
minHeight: "100px",
fontSize: "16px",
}}
/>
<div style={{ marginTop: "10px", fontSize: "12px", color: "#666" }}>
<p>바인딩 상태: {isReady ? "🟢 연결됨" : "🔴 연결 중..."}</p>
<p>
CRDT 텍스트: "<strong style={{ color: "blue" }}>{crdtText}</strong>"
</p>
<p>
DOM 텍스트: "<strong style={{ color: "green" }}>{domText}</strong>"
</p>
<p>동기화 상태: {crdtText === domText ? "🟢 동기화됨" : "🔴 불일치"}</p>

<div
style={{
marginTop: "8px",
padding: "8px",
backgroundColor: "#f0f8ff",
borderRadius: "4px",
}}
>
<strong>디버깅 가이드:</strong>
<ol style={{ marginLeft: "20px", fontSize: "11px" }}>
<li>콘솔 열기: F12 또는 Ctrl+Shift+I</li>
<li>에디터에 타이핑해보기</li>
<li>콘솔에서 로그 확인하기</li>
<li>CRDT 텍스트가 업데이트되는지 확인</li>
</ol>
</div>

<div
style={{
marginTop: "8px",
padding: "8px",
backgroundColor: "#f0f8ff",
borderRadius: "4px",
}}
>
<strong>개선사항:</strong>
<ul style={{ marginLeft: "20px", fontSize: "11px" }}>
<li>✅ 자동 변경 감지 (input 이벤트)</li>
<li>✅ 자동 캐럿 추적 (selectionchange 이벤트)</li>
<li>✅ 자동 CRDT 연산 생성</li>
<li>✅ 실시간 텍스트 동기화 확인</li>
<li>⏳ 원격 변경사항 반영 (TODO)</li>
</ul>
</div>
</div>
</div>
);
};
163 changes: 163 additions & 0 deletions nocta-doc/core/EditorBinding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import type { Nocta } from "./Nocta";

export class EditorBinding {
private element: HTMLElement;
private nocta: Nocta;
private blockId: string;
private prevText: string = "";
private isUpdating: boolean = false;

constructor(nocta: Nocta, element: HTMLElement, blockId: string) {
console.log("🔗 EditorBinding 생성 중...", { blockId, element });

this.nocta = nocta;
this.element = element;
this.blockId = blockId;
this.prevText = element.innerText || "";

this.attachEventListeners();
this.syncFromCRDT();

console.log("✅ EditorBinding 생성 완료!", { blockId, initialText: this.prevText });
}

private attachEventListeners() {
console.log("🎧 이벤트 리스너 연결 중...");

// DOM 변경 감지
this.element.addEventListener("input", this.handleInput);

// 캐럿 위치 추적
document.addEventListener("selectionchange", this.handleSelectionChange);

console.log("✅ 이벤트 리스너 연결 완료!");
}

private handleInput = () => {
if (this.isUpdating) {
return;
}

const newText = this.element.innerText || "";
const oldText = this.prevText;

// 변경사항 계산 및 CRDT에 반영
const changes = this.calculateChanges(oldText, newText);

this.applyChangesToCRDT(changes);

this.prevText = newText;
};

private handleSelectionChange = () => {
if (!this.element.contains(document.activeElement)) return;

const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const offset = this.getTextOffset(range.startContainer, range.startOffset);

console.log("🎯 캐럿 위치 변경:", { offset });

// NoctaRealm을 통해 캐럿 위치 브로드캐스트
this.nocta.setCaret(this.blockId, String(offset));
}
};

private calculateChanges(oldText: string, newText: string) {
const selection = window.getSelection();
let cursorPosition = 0;

if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
cursorPosition = this.getTextOffset(range.startContainer, range.startOffset);
}

if (newText.length > oldText.length) {
// 삽입
const insertedLength = newText.length - oldText.length;
let insertPosition = cursorPosition - insertedLength;

if (insertPosition < 0) insertPosition = 0;
if (insertPosition > oldText.length) insertPosition = oldText.length;

const insertedText = newText.slice(insertPosition, insertPosition + insertedLength);

return {
type: "insert" as const,
position: insertPosition,
text: insertedText,
length: insertedLength,
};
} else if (newText.length < oldText.length) {
// 삭제
const deletedLength = oldText.length - newText.length;
let deletePosition = cursorPosition;

if (deletePosition < 0) deletePosition = 0;
if (deletePosition > newText.length) deletePosition = newText.length;

return {
type: "delete" as const,
position: deletePosition,
length: deletedLength,
};
}

return null;
}

private applyChangesToCRDT(changes: any) {
if (!changes) {
return;
}

if (changes.type === "insert") {
console.log(`📝 삽입: 위치 ${changes.position}, 텍스트 "${changes.text}"`);

// 각 문자를 개별적으로 삽입
for (let i = 0; i < changes.text.length; i++) {
console.log(` - 문자 삽입: "${changes.text[i]}" at ${changes.position + i}`);
this.nocta.insertChar(this.blockId, changes.position + i, changes.text[i]);
}
} else if (changes.type === "delete") {
console.log(`🗑️ 삭제: 위치 ${changes.position}, 길이 ${changes.length}`);

// 연속된 문자 삭제
for (let i = 0; i < changes.length; i++) {
console.log(` - 문자 삭제: position ${changes.position}`);
this.nocta.deleteChar(this.blockId, changes.position);
}
}
}

private getTextOffset(node: Node, offset: number): number {
let textOffset = 0;
const walker = document.createTreeWalker(this.element, NodeFilter.SHOW_TEXT, null);

let currentNode;
while ((currentNode = walker.nextNode())) {
if (currentNode === node) {
return textOffset + offset;
}
textOffset += currentNode.textContent?.length || 0;
}

return textOffset;
}

// CRDT 변경사항을 DOM에 반영
private syncFromCRDT() {
// TODO: CRDT에서 변경사항을 감지하고 DOM 업데이트
// 현재는 단방향 (DOM -> CRDT)만 구현
console.log("🔄 CRDT 동기화 (현재는 TODO)");
}

// 바인딩 해제
destroy() {
console.log("🧹 EditorBinding 정리 중...");
this.element.removeEventListener("input", this.handleInput);
document.removeEventListener("selectionchange", this.handleSelectionChange);
console.log("✅ EditorBinding 정리 완료!");
}
}
19 changes: 19 additions & 0 deletions nocta-doc/core/Nocta.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NoctaDoc } from "./NoctaDoc";
import { ClientNoctaRealm, ServerNoctaRealm } from "./NoctaRealm";
import { EditorBinding } from "./EditorBinding";

export class Nocta {
private doc: NoctaDoc;
Expand All @@ -22,6 +23,16 @@ export class Nocta {
return new Nocta(doc, realm);
}

// 바인딩 API 추가
bindToEditor(element: HTMLElement, blockId: string): EditorBinding {
// 블록이 없으면 생성
if (!this.hasBlock(blockId)) {
this.insertBlock(null, blockId, "paragraph");
}

return new EditorBinding(this, element, blockId);
}

setCaret(blockId: string, charId: string) {
this.realm.setCaret(blockId, charId);
}
Expand All @@ -46,6 +57,14 @@ export class Nocta {
return this.doc.getText(blockId);
}

hasBlock(blockId: string): boolean {
return this.doc.hasBlock(blockId);
}

getTextSafe(blockId: string): string {
return this.doc.getTextSafe(blockId);
}

insertBlock(prevId: string | null, blockId: string, type: string) {
this.doc.insertBlock(prevId, blockId, type);
}
Expand Down
11 changes: 11 additions & 0 deletions nocta-doc/core/NoctaDoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,15 @@ export class NoctaDoc {
const block = this.blockCRDT.getBlock(blockId);
return block.charCRDT.getText();
}

hasBlock(blockId: string): boolean {
return this.blockCRDT.hasBlock(blockId);
}

getTextSafe(blockId: string): string {
if (!this.hasBlock(blockId)) {
return "";
}
return this.getText(blockId);
}
}
4 changes: 4 additions & 0 deletions nocta-doc/crdt/BlockCRDT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ export class BlockCRDT {
return block;
}

hasBlock(blockId: string): boolean {
return this.blocks.has(blockId);
}

collectOperations(): Operation[] {
const ops: Operation[] = [];
let currentId = this.headId;
Expand Down
1 change: 1 addition & 0 deletions nocta-doc/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { Nocta } from "./core/Nocta";
export { EditorBinding } from "./core/EditorBinding";
Loading