diff --git a/client/src/TestEditor.tsx b/client/src/TestEditor.tsx index c4f069c..fc281b6 100644 --- a/client/src/TestEditor.tsx +++ b/client/src/TestEditor.tsx @@ -1,5 +1,5 @@ import { Nocta } from "@noctaDoc"; -import { useRef, useEffect } from "react"; +import { useRef, useEffect, useState } from "react"; const socket = { on: () => {}, @@ -10,36 +10,49 @@ const client = Nocta.createClient({ socket, clientId: "client-123" }); export const TestEditor = () => { const editorRef = useRef(null); - const prevText = useRef(""); + const [binding, setBinding] = useState(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 (
-

TestEditor

+

TestEditor - 바인딩 시스템 적용 🚀

{ fontSize: "16px", }} /> +
+

바인딩 상태: {isReady ? "🟢 연결됨" : "🔴 연결 중..."}

+

+ CRDT 텍스트: "{crdtText}" +

+

+ DOM 텍스트: "{domText}" +

+

동기화 상태: {crdtText === domText ? "🟢 동기화됨" : "🔴 불일치"}

+ +
+ 디버깅 가이드: +
    +
  1. 콘솔 열기: F12 또는 Ctrl+Shift+I
  2. +
  3. 에디터에 타이핑해보기
  4. +
  5. 콘솔에서 로그 확인하기
  6. +
  7. CRDT 텍스트가 업데이트되는지 확인
  8. +
+
+ +
+ 개선사항: +
    +
  • ✅ 자동 변경 감지 (input 이벤트)
  • +
  • ✅ 자동 캐럿 추적 (selectionchange 이벤트)
  • +
  • ✅ 자동 CRDT 연산 생성
  • +
  • ✅ 실시간 텍스트 동기화 확인
  • +
  • ⏳ 원격 변경사항 반영 (TODO)
  • +
+
+
); }; diff --git a/nocta-doc/core/EditorBinding.ts b/nocta-doc/core/EditorBinding.ts new file mode 100644 index 0000000..fadfc4f --- /dev/null +++ b/nocta-doc/core/EditorBinding.ts @@ -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 정리 완료!"); + } +} diff --git a/nocta-doc/core/Nocta.ts b/nocta-doc/core/Nocta.ts index 7330334..b58ed21 100644 --- a/nocta-doc/core/Nocta.ts +++ b/nocta-doc/core/Nocta.ts @@ -1,5 +1,6 @@ import { NoctaDoc } from "./NoctaDoc"; import { ClientNoctaRealm, ServerNoctaRealm } from "./NoctaRealm"; +import { EditorBinding } from "./EditorBinding"; export class Nocta { private doc: NoctaDoc; @@ -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); } @@ -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); } diff --git a/nocta-doc/core/NoctaDoc.ts b/nocta-doc/core/NoctaDoc.ts index 19d9949..878535c 100644 --- a/nocta-doc/core/NoctaDoc.ts +++ b/nocta-doc/core/NoctaDoc.ts @@ -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); + } } diff --git a/nocta-doc/crdt/BlockCRDT.ts b/nocta-doc/crdt/BlockCRDT.ts index 7873fb9..6e802bd 100644 --- a/nocta-doc/crdt/BlockCRDT.ts +++ b/nocta-doc/crdt/BlockCRDT.ts @@ -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; diff --git a/nocta-doc/index.ts b/nocta-doc/index.ts index 7a49cfe..a5da78c 100644 --- a/nocta-doc/index.ts +++ b/nocta-doc/index.ts @@ -1 +1,2 @@ export { Nocta } from "./core/Nocta"; +export { EditorBinding } from "./core/EditorBinding";