Skip to content

Refactor/#047 nocta라이브러리 리팩토링 #48

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

Open
wants to merge 9 commits into
base: refactor/noctaDoc
Choose a base branch
from
Open
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
52 changes: 52 additions & 0 deletions client/src/TestEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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>
);
};
38 changes: 24 additions & 14 deletions client/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// import { StrictMode } from "react";
// import { createRoot } from "react-dom/client";
// import "./index.css";
// import App from "./App.tsx";

// const queryClient = new QueryClient({
// defaultOptions: {
// queries: {
// refetchOnWindowFocus: false,
// },
// },
// });

// createRoot(document.getElementById("root")!).render(
// <StrictMode>
// <QueryClientProvider client={queryClient}>
// <App />
// </QueryClientProvider>
// </StrictMode>,
// );

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";

const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
import { TestEditor } from "./TestEditor";

createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
<TestEditor />
</StrictMode>,
);
6 changes: 4 additions & 2 deletions client/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@
"@apis/*": ["src/apis/*"],
"@stores/*": ["src/stores/*"],
"@noctaCrdt": ["../@noctaCrdt/src"],
"@noctaCrdt/*": ["../@noctaCrdt/src/*"]
"@noctaCrdt/*": ["../@noctaCrdt/src/*"],
"@noctaDoc": ["../nocta-doc"],
"@noctaDoc/*": ["../nocta-doc/*"]
}
},
"references": [{ "path": "../@noctaCrdt" }],
"references": [{ "path": "../@noctaCrdt" }, { "path": "../nocta-doc" }],
"include": ["src", "*.ts", "*.tsx", "vite.config.ts", "styled-system"],
"exclude": ["node_modules"]
}
52 changes: 52 additions & 0 deletions nocta-doc/core/Nocta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { NoctaDoc } from "./NoctaDoc";
import { ClientNoctaRealm, ServerNoctaRealm } from "./NoctaRealm";

export class Nocta {
private doc: NoctaDoc;
private realm: ClientNoctaRealm | ServerNoctaRealm;

private constructor(doc: NoctaDoc, realm: ClientNoctaRealm | ServerNoctaRealm) {
this.doc = doc;
this.realm = realm;
}

static createClient({ socket, clientId }): Nocta {
const doc = new NoctaDoc(clientId);
const realm = new ClientNoctaRealm(socket, doc);
return new Nocta(doc, realm);
}

static createServer({ socket, clientId }): Nocta {
const doc = new NoctaDoc(clientId);
const realm = new ServerNoctaRealm(socket, doc);
return new Nocta(doc, realm);
}

setCaret(blockId: string, charId: string) {
this.realm.setCaret(blockId, charId);
}

insertChar(blockId: string, index: number, value: string) {
this.doc.insertChar(blockId, index, value);
}

deleteChar(blockId: string, index: number) {
this.doc.deleteChar(blockId, index);
}

applyUpdate(update: Uint8Array) {
this.doc.applyUpdate(update);
}

encodeUpdate(): Uint8Array {
return this.doc.encodeUpdate();
}

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

insertBlock(prevId: string | null, blockId: string, type: string) {
this.doc.insertBlock(prevId, blockId, type);
}
}
59 changes: 59 additions & 0 deletions nocta-doc/core/NoctaDoc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { BlockCRDT } from "../crdt/BlockCRDT";
import { IdGenerator, NodeId } from "../crdt/Id";
import type { Operation } from "../operations";

export class NoctaDoc {
public blockCRDT: BlockCRDT;
public clientId: string;
private idGenerator: IdGenerator;

constructor(clientId: string) {
this.clientId = clientId;
this.idGenerator = new IdGenerator(clientId);
this.blockCRDT = new BlockCRDT(clientId);
}

insertChar(blockId: string, index: number, value: string) {
const nextId: NodeId = this.idGenerator.next();
const id: string = this.idGenerator.toString(nextId);
const block = this.blockCRDT.getBlock(blockId);
block.charCRDT.insert(index, id, value);
}

deleteChar(blockId: string, index: number) {
const block = this.blockCRDT.getBlock(blockId);
block.charCRDT.delete(index);
}

insertBlock(prevId: string | null, blockId: string, type: string) {
this.blockCRDT.insertBlockAfter(prevId, blockId, type);
}

applyUpdate(update: Uint8Array) {
const json = new TextDecoder().decode(update);
const operations: Operation[] = JSON.parse(json);
this.blockCRDT.applyOperations(operations);
}

// TODO: Define a proper encoding strategy for updates (e.g., CBOR, JSON, VarInt)
// and decide on the structure of operation messages (insert, delete, etc.)
encodeUpdate(): Uint8Array {
const operations: Operation[] = this.blockCRDT.collectOperations(); // 가정
const json = JSON.stringify(operations);
return new TextEncoder().encode(json);
}

private mergeUpdates(updates: Uint8Array[]): Uint8Array {
return updates.reduce((acc, cur) => {
const merged = new Uint8Array(acc.length + cur.length);
merged.set(acc);
merged.set(cur, acc.length);
return merged;
});
}

getText(blockId: string): string {
const block = this.blockCRDT.getBlock(blockId);
return block.charCRDT.getText();
}
}
45 changes: 45 additions & 0 deletions nocta-doc/core/NoctaRealm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Socket as ServerSocket } from "socket.io";
import type { Socket as ClientSocket } from "socket.io-client";
import type { NoctaDoc } from "./NoctaDoc";
import { Operation } from "../operations";

export class ClientNoctaRealm {
constructor(
private socket: ClientSocket,
private doc: NoctaDoc,
) {
this.socket.on("operation", (ops: Operation[]) => {
this.doc.blockCRDT.applyOperations(ops);
});
}

sendOperations(ops: Operation[]) {
this.socket.emit("operation", ops);
}

setCaret(blockId: string, charId: string) {
// TODO: 브로드캐스트 방식 변경 가능
this.socket.emit("setCaret", { blockId, charId });
}
}
// TODO: io 매개변수 : 특정 room번호에 송신
export class ServerNoctaRealm {
constructor(
private socket: ServerSocket,
private doc: NoctaDoc,
) {
this.socket.on("operation", (ops: Operation[]) => {
this.doc.blockCRDT.applyOperations(ops);
this.socket.broadcast.emit("operation", ops);
});
this.socket.on("requestInit", () => {
const ops = this.doc.blockCRDT.collectOperations();
this.socket.emit("initDoc", ops);
});
}

setCaret(blockId: string, charId: string) {
// TODO: room 기반으로 전파하려면 socket.to(room).emit 등 사용
this.socket.broadcast.emit("setCaret", { blockId, charId });
}
}
114 changes: 114 additions & 0 deletions nocta-doc/crdt/BlockCRDT.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { Operation, InsertOperation } from "../operations";
import { CharCRDT } from "./CharCRDT";
import { BlockNode } from "../model/Block";

export class BlockCRDT {
private blocks: Map<string, BlockNode> = new Map();
public clientId: string;
private headId: string | null = null;
private tailId: string | null = null;

constructor(clientId: string) {
this.clientId = clientId;
}

insertBlockAfter(prevId: string | null, blockId: string, type: string): void {
const newBlock: BlockNode = {
id: blockId,
type,
prevId,
nextId: null,
charCRDT: new CharCRDT(this.clientId),
};

if (prevId === null) {
// Insert at head
const oldHead = this.headId ? this.blocks.get(this.headId) : null;
newBlock.nextId = this.headId;
if (oldHead) oldHead.prevId = blockId;
this.headId = blockId;
if (!this.tailId) this.tailId = blockId;
} else {
const prevBlock = this.blocks.get(prevId);
if (!prevBlock) throw new Error(`Block ${prevId} not found`);
const { nextId } = prevBlock;
newBlock.nextId = nextId;
prevBlock.nextId = blockId;

if (nextId) {
const nextBlock = this.blocks.get(nextId);
if (nextBlock) nextBlock.prevId = blockId;
} else {
this.tailId = blockId;
}
}

this.blocks.set(blockId, newBlock);
}

deleteBlock(blockId: string): void {
const block = this.blocks.get(blockId);
if (!block) return;

const { prevId, nextId } = block;

if (prevId) {
const prev = this.blocks.get(prevId);
if (prev) prev.nextId = nextId;
} else {
this.headId = nextId;
}

if (nextId) {
const next = this.blocks.get(nextId);
if (next) next.prevId = prevId;
} else {
this.tailId = prevId;
}

this.blocks.delete(blockId);
}

getBlocks(): BlockNode[] {
const result: BlockNode[] = [];
let currentId = this.headId;
while (currentId) {
const node = this.blocks.get(currentId);
if (!node) break;
result.push(node);
currentId = node.nextId;
}
return result;
}
getBlock(blockId: string): BlockNode {
const block = this.blocks.get(blockId);
if (!block) throw new Error(`Block ${blockId} not found`);
return block;
}

collectOperations(): Operation[] {
const ops: Operation[] = [];
let currentId = this.headId;
while (currentId) {
const block = this.blocks.get(currentId);
if (!block) break;
ops.push({
type: "insertBlock",
node: block,
});
currentId = block.nextId;
}
return ops;
}

applyOperations(operations: Operation[]): void {
for (const op of operations) {
if (op.type === "insertBlock") {
const block = op.node;
if (!this.blocks.has(block.id)) {
this.insertBlockAfter(this.tailId, block.id, block.type);
}
}
}
}
}
Loading
Loading