From 66a069cf052943f1a2a785ebbc82f52795167c99 Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Fri, 25 Jul 2025 20:21:03 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=97=90=EB=94=94=ED=84=B0=20?= =?UTF-8?q?=EB=B0=94=EC=9D=B8=EB=94=A9=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 문자 삽입,삭제 관련 연산 담당 - 실제로 눈에 보이는 임시 가상 에디터 역할 - y.doc() 느낌 - 문자를 개별적으로 삽입할때 일어나는 내부 비지니스 로직을 한단계 감싸는 역할 --- client/src/TestEditor.tsx | 100 ++++++++++++++++---- nocta-doc/core/EditorBinding.ts | 163 ++++++++++++++++++++++++++++++++ nocta-doc/core/Nocta.ts | 19 ++++ nocta-doc/index.ts | 1 + 4 files changed, 262 insertions(+), 21 deletions(-) create mode 100644 nocta-doc/core/EditorBinding.ts 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/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"; From 10331ba9479999a5cf03f0373683c46f54fd0675 Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Fri, 25 Jul 2025 20:21:33 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EB=B8=94=EB=A1=9D=20=EC=9C=A0?= =?UTF-8?q?=EB=AC=B4=20=ED=8C=90=EB=8B=A8=ED=95=98=EB=8A=94=20hasBlock=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=EC=99=80=20getText()=EB=A5=BC=20=EC=95=88?= =?UTF-8?q?=EC=A0=84=ED=95=98=EA=B2=8C=20=ED=95=98=EB=8A=94=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nocta-doc/core/NoctaDoc.ts | 11 +++++++++++ nocta-doc/crdt/BlockCRDT.ts | 4 ++++ 2 files changed, 15 insertions(+) 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;