From ac12282defe55a30138ba6ffa6d435c01035cd1d Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sat, 23 Nov 2024 15:47:07 +0900 Subject: [PATCH 01/47] =?UTF-8?q?feat:=20Char=20style=20=EC=86=8D=EC=84=B1?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- @noctaCrdt/Crdt.ts | 14 +++++++++++++- @noctaCrdt/Interfaces.ts | 8 ++++++++ @noctaCrdt/Node.ts | 6 +++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/@noctaCrdt/Crdt.ts b/@noctaCrdt/Crdt.ts index 674c25a0..0a32a275 100644 --- a/@noctaCrdt/Crdt.ts +++ b/@noctaCrdt/Crdt.ts @@ -9,6 +9,7 @@ import { CRDTSerializedProps, RemoteBlockReorderOperation, RemoteBlockUpdateOperation, + RemoteCharUpdateOperation, } from "./Interfaces"; export class CRDT> { @@ -219,7 +220,7 @@ export class BlockCRDT extends CRDT { pageId: string, ): RemoteCharInsertOperation { const id = new CharId(this.clock + 1, this.client); - const { node } = this.LinkedList.insertAtIndex(index, value, id); + const { node } = this.LinkedList.insertAtIndex(index, value, id) as { node: Char }; this.clock += 1; const operation: RemoteCharInsertOperation = { node, @@ -253,6 +254,12 @@ export class BlockCRDT extends CRDT { return operation; } + localUpdate(node: Char, blockId: BlockId, pageId: string): RemoteCharUpdateOperation { + const updatedChar = this.LinkedList.nodeMap[JSON.stringify(node.id)]; + updatedChar.style = [...node.style]; + return { node: updatedChar, blockId, pageId }; + } + remoteInsert(operation: RemoteCharInsertOperation): void { const newNodeId = new CharId(operation.node.id.clock, operation.node.id.client); const newNode = new Char(operation.node.value, newNodeId); @@ -278,6 +285,11 @@ export class BlockCRDT extends CRDT { } } + remoteUpdate(operation: RemoteCharUpdateOperation): void { + const updatedChar = this.LinkedList.nodeMap[JSON.stringify(operation.node.id)]; + updatedChar.style = [...operation.node.style]; + } + serialize(): CRDTSerializedProps { return { ...super.serialize(), diff --git a/@noctaCrdt/Interfaces.ts b/@noctaCrdt/Interfaces.ts index 40b7d0e3..d9cd3ba8 100644 --- a/@noctaCrdt/Interfaces.ts +++ b/@noctaCrdt/Interfaces.ts @@ -7,6 +7,8 @@ export type ElementType = "p" | "h1" | "h2" | "h3" | "ul" | "ol" | "li" | "check export type AnimationType = "none" | "highlight" | "gradation"; +export type TextStyleType = "bold" | "italic" | "underline" | "strikethrough"; + export interface InsertOperation { node: Block | Char; } @@ -51,6 +53,12 @@ export interface RemoteCharDeleteOperation { pageId: string; } +export interface RemoteCharUpdateOperation { + node: Char; + blockId: BlockId; + pageId: string; +} + export interface CursorPosition { clientId: number; position: number; diff --git a/@noctaCrdt/Node.ts b/@noctaCrdt/Node.ts index 8591e586..1e6953fa 100644 --- a/@noctaCrdt/Node.ts +++ b/@noctaCrdt/Node.ts @@ -1,6 +1,6 @@ // Node.ts import { NodeId, BlockId, CharId } from "./NodeId"; -import { AnimationType, ElementType } from "./Interfaces"; +import { AnimationType, ElementType, TextStyleType } from "./Interfaces"; import { BlockCRDT } from "./Crdt"; export abstract class Node { @@ -86,8 +86,11 @@ export class Block extends Node { } export class Char extends Node { + style: TextStyleType[]; + constructor(value: string, id: CharId) { super(value, id); + this.style = []; } serialize(): any { @@ -99,6 +102,7 @@ export class Char extends Node { const char = new Char(data.value, id); char.next = data.next ? CharId.deserialize(data.next) : null; char.prev = data.prev ? CharId.deserialize(data.prev) : null; + char.style = data.style ? data.style : []; return char; } } From 5563a3558120a569acba58be4edafe61d479295b Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sun, 24 Nov 2024 00:46:30 +0900 Subject: [PATCH 02/47] =?UTF-8?q?build:=20dompurify=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/package.json | 1 + pnpm-lock.yaml | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/client/package.json b/client/package.json index fd4f0b80..08c4a90c 100644 --- a/client/package.json +++ b/client/package.json @@ -33,6 +33,7 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", + "dompurify": "^3.2.1", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jsx-a11y": "^6.8.0", "eslint-plugin-react": "^7.33.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e6ca32a..3d8b3b09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.3 version: 4.3.3(vite@5.4.10(@types/node@20.17.6)(lightningcss@1.25.1)(terser@5.36.0)) + dompurify: + specifier: ^3.2.1 + version: 3.2.1 eslint-plugin-import: specifier: ^2.29.1 version: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.3.3))(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1) @@ -1568,6 +1571,9 @@ packages: '@types/supertest@6.0.2': resolution: {integrity: sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/webidl-conversions@7.0.3': resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} @@ -2396,6 +2402,9 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dompurify@3.2.1: + resolution: {integrity: sha512-NBHEsc0/kzRYQd+AY6HR6B/IgsqzBABrqJbpCDQII/OK6h7B7LXzweZTDsqSW2LkTRpoxf18YUP+YjGySk6B3w==} + dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -6743,6 +6752,9 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/trusted-types@2.0.7': + optional: true + '@types/webidl-conversions@7.0.3': {} '@types/whatwg-url@11.0.5': @@ -7674,6 +7686,10 @@ snapshots: dependencies: esutils: 2.0.3 + dompurify@3.2.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dot-case@3.0.4: dependencies: no-case: 3.0.4 From 6427329712eb5809870362075bbcc5a2236694b2 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sun, 24 Nov 2024 02:27:52 +0900 Subject: [PATCH 03/47] =?UTF-8?q?feat:=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=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 --- client/src/constants/option.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/client/src/constants/option.ts b/client/src/constants/option.ts index 75193a17..010e7396 100644 --- a/client/src/constants/option.ts +++ b/client/src/constants/option.ts @@ -1,4 +1,4 @@ -import { AnimationType, ElementType } from "@noctaCrdt/Interfaces"; +import { AnimationType, ElementType, TextStyleType } from "@noctaCrdt/Interfaces"; export const OPTION_CATEGORIES = { TYPE: { @@ -36,4 +36,18 @@ export const OPTION_CATEGORIES = { }, }; +export const TEXT_OPTION_CATEGORIES = { + TYPE: { + id: "textType", + label: "글자", + options: [ + { id: "bold", label: "굵게" }, + { id: "italic", label: "기울임" }, + { id: "underline", label: "밑줄" }, + { id: "strikethrough", label: "취소선" }, + ] as { id: TextStyleType; label: string }[], + }, +}; + export type OptionCategory = keyof typeof OPTION_CATEGORIES; +export type TextOptionCategory = keyof typeof TEXT_OPTION_CATEGORIES; From 8a3543ff08890a23028d5c79469a3fe35f21a053 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sun, 24 Nov 2024 02:28:20 +0900 Subject: [PATCH 04/47] =?UTF-8?q?feat:=20useBlockOption=20=EC=84=9C?= =?UTF-8?q?=EC=9C=A4=EB=8B=98=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/editor/hooks/useBlockOption.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/client/src/features/editor/hooks/useBlockOption.ts b/client/src/features/editor/hooks/useBlockOption.ts index 6b023bd5..35a6799c 100644 --- a/client/src/features/editor/hooks/useBlockOption.ts +++ b/client/src/features/editor/hooks/useBlockOption.ts @@ -118,6 +118,12 @@ export const useBlockOptionSelect = ({ }); }; + const findBlock = (linkedList: BlockLinkedList, index: number) => { + if (index < 0) return null; + if (index >= linkedList.spread().length) return null; + return linkedList.findByIndex(index); + }; + const handleDeleteSelect = (blockId: BlockId) => { const currentIndex = editorCRDT.LinkedList.spread().findIndex((block) => block.id.equals(blockId), @@ -127,8 +133,9 @@ export const useBlockOptionSelect = ({ // 삭제할 블록이 현재 활성화된 블록인 경우 if (editorCRDT.currentBlock?.id.equals(blockId)) { // 다음 블록이나 이전 블록으로 currentBlock 설정 - const nextBlock = editorCRDT.LinkedList.findByIndex(currentIndex + 1); - const prevBlock = editorCRDT.LinkedList.findByIndex(currentIndex - 1); + // 서윤님 피드백 반영 + const nextBlock = findBlock(editorCRDT.LinkedList, currentIndex); // ✅ 이미 삭제한 후라, next는 currentIndex + const prevBlock = findBlock(editorCRDT.LinkedList, currentIndex - 1); // ✅ 이미 삭제한 후라, prev는 currentIndex - 1 editorCRDT.currentBlock = nextBlock || prevBlock || null; } From 35c6e55d4a8a3bbaa71d4212d3ab676d31661140 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sun, 24 Nov 2024 02:28:42 +0900 Subject: [PATCH 05/47] =?UTF-8?q?feat:=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20=EB=AA=A8=EB=8B=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TextOptionModal/TextOptionModal.style.ts | 32 ++++++ .../TextOptionModal/TextOptionModal.tsx | 97 +++++++++++++++++++ .../features/editor/hooks/useTextOptions.ts | 76 +++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 client/src/features/editor/components/TextOptionModal/TextOptionModal.style.ts create mode 100644 client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx create mode 100644 client/src/features/editor/hooks/useTextOptions.ts diff --git a/client/src/features/editor/components/TextOptionModal/TextOptionModal.style.ts b/client/src/features/editor/components/TextOptionModal/TextOptionModal.style.ts new file mode 100644 index 00000000..18c42634 --- /dev/null +++ b/client/src/features/editor/components/TextOptionModal/TextOptionModal.style.ts @@ -0,0 +1,32 @@ +import { css } from "@styled-system/css"; + +export const optionModal = css({ + zIndex: 1000, + position: "fixed", + borderRadius: "4px", + background: "white", + boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)", +}); + +export const modalContainer = css({ + display: "flex", + gap: "4px", + padding: "8px", +}); + +export const optionButton = css({ + display: "flex", + justifyContent: "center", + + alignItems: "center", + border: "none", + borderRadius: "4px", + minWidth: "28px", + height: "28px", + padding: "4px 8px", + background: "#f5f5f5", + cursor: "pointer", + "&:hover": { + background: "#e0e0e0", + }, +}); diff --git a/client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx b/client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx new file mode 100644 index 00000000..30026afe --- /dev/null +++ b/client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx @@ -0,0 +1,97 @@ +import { motion } from "framer-motion"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { modalContainer, optionButton, optionModal } from "./TextOptionModal.style"; + +interface SelectionModalProps { + isOpen: boolean; + onBoldSelect: () => void; + onItalicSelect: () => void; + onUnderlineSelect: () => void; + onStrikeSelect: () => void; + onClose: () => void; +} + +const ModalPortal = ({ children }: { children: React.ReactNode }) => { + return createPortal(children, document.body); +}; + +export const TextOptionModal = ({ + isOpen, + onBoldSelect, + onItalicSelect, + onUnderlineSelect, + onStrikeSelect, + onClose, +}: SelectionModalProps) => { + const modalRef = useRef(null); + const [TextModalPosition, setTextModalPosition] = useState<{ top: number; left: number }>({ + top: 0, + left: 0, + }); + + useEffect(() => { + if (!isOpen) return; + + const updateModalPosition = () => { + const selection = window.getSelection(); + if (!selection || selection.isCollapsed) { + onClose(); + return; + } + + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + setTextModalPosition({ + top: rect.top - 40, + left: rect.left + rect.width / 2, + }); + }; + + const handleClickOutside = (e: MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + onClose(); + } + }; + + updateModalPosition(); + document.addEventListener("mousedown", handleClickOutside); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen, onClose]); + + if (!isOpen) return; + + return ( + + {isOpen && ( + +
+ + + + +
+
+ )} +
+ ); +}; diff --git a/client/src/features/editor/hooks/useTextOptions.ts b/client/src/features/editor/hooks/useTextOptions.ts new file mode 100644 index 00000000..a86d8b87 --- /dev/null +++ b/client/src/features/editor/hooks/useTextOptions.ts @@ -0,0 +1,76 @@ +import { EditorCRDT } from "@noctaCrdt/Crdt"; +import { RemoteBlockUpdateOperation, TextStyleType } from "@noctaCrdt/Interfaces"; +import { Block, Char } from "@noctaCrdt/Node"; +import { BlockId } from "@noctaCrdt/NodeId"; +import { useCallback } from "react"; +import { useSocketStore } from "@src/stores/useSocketStore"; +import { EditorStateProps } from "../Editor"; + +interface UseTextOptionSelectProps { + editorCRDT: EditorCRDT; + setEditorState: React.Dispatch>; + pageId: string; + sendBlockUpdateOperation: (operation: RemoteBlockUpdateOperation) => void; +} + +export const useTextOptionSelect = ({ + editorCRDT, + setEditorState, + pageId, + sendBlockUpdateOperation, +}: UseTextOptionSelectProps) => { + const { sendCharUpdateOperation } = useSocketStore(); + + const handleStyleUpdate = useCallback( + (styleType: TextStyleType, blockId: BlockId, nodes: Array) => { + if (!nodes) return; + + nodes.forEach((node) => { + const block = editorCRDT.LinkedList.getNode(blockId) as Block; + if (!block) return; + const char = block.crdt.LinkedList.getNode(node.id) as Char; + if (!char) return; + + const toggleStyle = (style: TextStyleType) => { + if (char.style.includes(style)) { + char.style = char.style.filter((s) => s !== style); + } else { + char.style.push(style); + } + }; + + switch (styleType) { + case "bold": + toggleStyle("bold"); + break; + case "italic": + toggleStyle("italic"); + break; + case "underline": + toggleStyle("underline"); + break; + case "strikethrough": + toggleStyle("strikethrough"); + break; + } + + block.crdt.localUpdate(char, node.id, pageId); + + sendCharUpdateOperation({ + node: char, + blockId, + pageId, + }); + }); + setEditorState({ + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, + }); + }, + [pageId, sendBlockUpdateOperation], + ); + + return { + onTextStyleUpdate: handleStyleUpdate, + }; +}; From 2dc8e0bd89f1bcadb5a4708b3eaff7a57bd0de0f Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sun, 24 Nov 2024 02:29:26 +0900 Subject: [PATCH 06/47] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20style=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- @noctaCrdt/Node.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/@noctaCrdt/Node.ts b/@noctaCrdt/Node.ts index 1e6953fa..77eb0ede 100644 --- a/@noctaCrdt/Node.ts +++ b/@noctaCrdt/Node.ts @@ -8,12 +8,14 @@ export abstract class Node { value: string; next: T | null; prev: T | null; + style: string[]; constructor(value: string, id: T) { this.id = id; this.value = value; this.next = null; this.prev = null; + this.style = []; } precedes(node: Node): boolean { @@ -32,6 +34,7 @@ export abstract class Node { value: this.value, next: this.next ? this.next.serialize() : null, prev: this.prev ? this.prev.serialize() : null, + style: this.style, }; } @@ -86,7 +89,7 @@ export class Block extends Node { } export class Char extends Node { - style: TextStyleType[]; + style: string[]; constructor(value: string, id: CharId) { super(value, id); From 189d3e9198687df8d467cc987b14bb2f5a11bb70 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sun, 24 Nov 2024 02:29:42 +0900 Subject: [PATCH 07/47] =?UTF-8?q?feat:=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8=20=ED=96=88=EC=9D=84=20=EB=95=8C=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EB=90=9C=20=ED=85=8D=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=93=A4=20=EC=B0=BE=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- @noctaCrdt/LinkedList.ts | 44 ++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/@noctaCrdt/LinkedList.ts b/@noctaCrdt/LinkedList.ts index 74a1109e..9e15a07b 100644 --- a/@noctaCrdt/LinkedList.ts +++ b/@noctaCrdt/LinkedList.ts @@ -219,52 +219,60 @@ export abstract class LinkedList> { this.setNode(node.id, node); } - stringify(): string { + getNodesBetween(startIndex: number, endIndex: number): T[] { + if (startIndex < 0 || endIndex < startIndex) { + throw new Error("Invalid indices"); + } + + const result: T[] = []; let currentNodeId = this.head; - let result = ""; + let currentIndex = 0; - while (currentNodeId !== null) { + // 시작 인덱스까지 이동 + while (currentNodeId !== null && currentIndex < startIndex) { const currentNode = this.getNode(currentNodeId); if (!currentNode) break; - result += currentNode.value; currentNodeId = currentNode.next; + currentIndex += 1; + } + + // 시작 인덱스부터 끝 인덱스까지의 노드들 수집 + while (currentNodeId !== null && currentIndex < endIndex) { + const currentNode = this.getNode(currentNodeId); + if (!currentNode) break; + result.push(currentNode); + currentNodeId = currentNode.next; + currentIndex += 1; } return result; } - spread(): T[] { + stringify(): string { let currentNodeId = this.head; - const result: T[] = []; + let result = ""; + while (currentNodeId !== null) { const currentNode = this.getNode(currentNodeId); if (!currentNode) break; - result.push(currentNode!); + result += currentNode.value; currentNodeId = currentNode.next; } + return result; } - /* spread(): T[] { - const visited = new Set(); let currentNodeId = this.head; const result: T[] = []; - while (currentNodeId !== null) { - const nodeKey = JSON.stringify(currentNodeId); - if (visited.has(nodeKey)) break; // 순환 감지 - - visited.add(nodeKey); const currentNode = this.getNode(currentNodeId); if (!currentNode) break; - - result.push(currentNode); + result.push(currentNode!); currentNodeId = currentNode.next; } return result; -} - */ + } serialize(): any { return { From 20787b91ab2f66cdc725bda162fb08781092c265 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sun, 24 Nov 2024 02:30:00 +0900 Subject: [PATCH 08/47] =?UTF-8?q?feat:=20sendCharUpdateOperation=20?= =?UTF-8?q?=EC=86=8C=EC=BC=93=20=EC=97=B0=EA=B2=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/stores/useSocketStore.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/client/src/stores/useSocketStore.ts b/client/src/stores/useSocketStore.ts index ec5f8a03..7673e0cd 100644 --- a/client/src/stores/useSocketStore.ts +++ b/client/src/stores/useSocketStore.ts @@ -6,6 +6,7 @@ import { RemoteCharDeleteOperation, RemoteBlockUpdateOperation, RemoteBlockReorderOperation, + RemoteCharUpdateOperation, CursorPosition, WorkSpaceSerializedProps, } from "@noctaCrdt/Interfaces"; @@ -25,6 +26,7 @@ interface SocketStore { sendCharInsertOperation: (operation: RemoteCharInsertOperation) => void; sendBlockDeleteOperation: (operation: RemoteBlockDeleteOperation) => void; sendCharDeleteOperation: (operation: RemoteCharDeleteOperation) => void; + sendCharUpdateOperation: (operation: RemoteCharUpdateOperation) => void; sendBlockReorderOperation: (operation: RemoteBlockReorderOperation) => void; sendCursorPosition: (position: CursorPosition) => void; subscribeToRemoteOperations: (handlers: RemoteOperationHandlers) => (() => void) | undefined; @@ -39,6 +41,7 @@ interface RemoteOperationHandlers { onRemoteBlockReorder: (operation: RemoteBlockReorderOperation) => void; onRemoteCharInsert: (operation: RemoteCharInsertOperation) => void; onRemoteCharDelete: (operation: RemoteCharDeleteOperation) => void; + onRemoteCharUpdate: (operation: RemoteCharUpdateOperation) => void; onRemoteCursor: (position: CursorPosition) => void; } @@ -144,6 +147,11 @@ export const useSocketStore = create((set, get) => ({ socket?.emit("delete/char", operation); }, + sendCharUpdateOperation: (operation: RemoteCharUpdateOperation) => { + const { socket } = get(); + socket?.emit("update/char", operation); + }, + sendCursorPosition: (position: CursorPosition) => { const { socket } = get(); socket?.emit("cursor", position); @@ -164,6 +172,7 @@ export const useSocketStore = create((set, get) => ({ socket.on("reorder/block", handlers.onRemoteBlockReorder); socket.on("insert/char", handlers.onRemoteCharInsert); socket.on("delete/char", handlers.onRemoteCharDelete); + socket.on("update/char", handlers.onRemoteCharUpdate); socket.on("cursor", handlers.onRemoteCursor); return () => { @@ -173,6 +182,7 @@ export const useSocketStore = create((set, get) => ({ socket.off("reorder/block", handlers.onRemoteBlockReorder); socket.off("insert/char", handlers.onRemoteCharInsert); socket.off("delete/char", handlers.onRemoteCharDelete); + socket.off("update/char", handlers.onRemoteCharUpdate); socket.off("cursor", handlers.onRemoteCursor); }; }, From c2fd84728b623a6ae2a5e366cf032774ae6e95fa Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sun, 24 Nov 2024 02:30:14 +0900 Subject: [PATCH 09/47] =?UTF-8?q?feat:=20=EC=84=9C=EB=B2=84=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/crdt/crdt.gateway.ts | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/server/src/crdt/crdt.gateway.ts b/server/src/crdt/crdt.gateway.ts index e4e6d9ad..7a7ff352 100644 --- a/server/src/crdt/crdt.gateway.ts +++ b/server/src/crdt/crdt.gateway.ts @@ -18,6 +18,7 @@ import { RemoteBlockUpdateOperation, RemotePageCreateOperation, RemoteBlockReorderOperation, + RemoteCharUpdateOperation, CursorPosition, } from "@noctaCrdt/Interfaces"; import { Logger } from "@nestjs/common"; @@ -400,6 +401,44 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } } + @SubscribeMessage("update/char") + async handleCharUpdate( + @MessageBody() data: RemoteCharUpdateOperation, + @ConnectedSocket() client: Socket, + ): Promise { + const clientInfo = this.clientMap.get(client.id); + try { + this.logger.debug( + `Update 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, + JSON.stringify(data), + ); + + const currentPage = this.workSpaceService + .getWorkspace() + .pageList.find((p) => p.id === data.pageId); + if (!currentPage) { + throw new Error(`Page with id ${data.pageId} not found`); + } + const currentBlock = currentPage.crdt.LinkedList.nodeMap[JSON.stringify(data.blockId)]; + if (!currentBlock) { + throw new Error(`Block with id ${data.blockId} not found`); + } + currentBlock.crdt.remoteUpdate(data); + const operation = { + node: data.node, + blockId: data.blockId, + pageId: data.pageId, + }; + client.broadcast.emit("update/char", operation); + } catch (error) { + this.logger.error( + `Char Update 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, + error.stack, + ); + throw new WsException(`Update 연산 실패: ${error.message}`); + } + } + /** * 커서 위치 업데이트 처리 */ From 07315a0d948d79006ca6a1cead5e707231ce417f Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sun, 24 Nov 2024 02:30:52 +0900 Subject: [PATCH 10/47] =?UTF-8?q?feat:=20=EA=B5=AC=ED=98=84=ED=95=9C=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=97=90=EB=94=94=ED=84=B0=EC=99=80=20=EB=B8=94?= =?UTF-8?q?=EB=A1=9D=20=EA=B5=AC=EC=A1=B0=EC=97=90=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/editor/Editor.tsx | 54 ++++++++---------- .../editor/components/block/Block.tsx | 57 +++++++++++++++++-- 2 files changed, 74 insertions(+), 37 deletions(-) diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index 56bec69a..7f9edbdb 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -21,6 +21,7 @@ import { Block } from "./components/block/Block.tsx"; import { useBlockDragAndDrop } from "./hooks/useBlockDragAndDrop"; import { useBlockOptionSelect } from "./hooks/useBlockOption.ts"; import { useMarkdownGrammer } from "./hooks/useMarkdownGrammer"; +import { useTextOptionSelect } from "./hooks/useTextOptions.ts"; interface EditorProps { onTitleChange: (title: string) => void; @@ -84,6 +85,13 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr sendCharInsertOperation, }); + const { onTextStyleUpdate } = useTextOptionSelect({ + editorCRDT: editorCRDT.current, + setEditorState, + pageId, + sendBlockUpdateOperation, + }); + const handleTitleChange = (e: React.ChangeEvent) => { onTitleChange(e.target.value); }; @@ -109,35 +117,14 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr const [addedChar] = newContent; charNode = block.crdt.localInsert(0, addedChar, block.id, pageId); editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; - requestAnimationFrame(() => { - setCaretPosition({ - blockId: block.id, - linkedList: editorCRDT.current.LinkedList, - position: caretPosition, - }); - }); } else if (caretPosition > currentContent.length) { const addedChar = newContent[newContent.length - 1]; charNode = block.crdt.localInsert(currentContent.length, addedChar, block.id, pageId); editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; - requestAnimationFrame(() => { - setCaretPosition({ - blockId: block.id, - linkedList: editorCRDT.current.LinkedList, - position: caretPosition, - }); - }); } else { const addedChar = newContent[caretPosition - 1]; charNode = block.crdt.localInsert(caretPosition - 1, addedChar, block.id, pageId); editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; - requestAnimationFrame(() => { - setCaretPosition({ - blockId: block.id, - linkedList: editorCRDT.current.LinkedList, - position: caretPosition, - }); - }); } sendCharInsertOperation({ node: charNode.node, blockId: block.id, pageId }); } else if (newContent.length < currentContent.length) { @@ -145,13 +132,6 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr operationNode = block.crdt.localDelete(caretPosition, block.id, pageId); sendCharDeleteOperation(operationNode); editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; - requestAnimationFrame(() => { - setCaretPosition({ - blockId: block.id, - linkedList: editorCRDT.current.LinkedList, - position: caretPosition, - }); - }); } setEditorState({ clock: editorCRDT.current.clock, @@ -170,7 +150,8 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr linkedList: editorCRDT.current.LinkedList, position: editorCRDT.current.currentBlock?.crdt.currentCaret, }); - }, [editorCRDT.current.currentBlock?.crdt.read().length]); + // 서윤님 피드백 반영 + }, [editorCRDT.current.currentBlock?.id.serialize()]); useEffect(() => { if (subscriptionRef.current) return; @@ -224,8 +205,6 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onRemoteBlockUpdate: (operation) => { console.log(operation, "block : 업데이트 확인합니다이"); if (!editorCRDT.current) return; - // ?? - console.log("타입", operation.node); editorCRDT.current.remoteUpdate(operation.node, operation.pageId); setEditorState({ clock: editorCRDT.current.clock, @@ -243,6 +222,18 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr }); }, + onRemoteCharUpdate: (operation) => { + console.log(operation, "char : 업데이트 확인합니다이"); + if (!editorCRDT.current) return; + const targetBlock = + editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; + targetBlock.crdt.remoteUpdate(operation); + setEditorState({ + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, + }); + }, + onRemoteCursor: (position) => { console.log(position, "커서위치 수신"); }, @@ -295,6 +286,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onTypeSelect={handleTypeSelect} onCopySelect={handleCopySelect} onDeleteSelect={handleDeleteSelect} + onTextStyleUpdate={onTextStyleUpdate} /> ))} diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index e9fdfb9b..cc75de0c 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -1,13 +1,15 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { AnimationType, ElementType } from "@noctaCrdt/Interfaces"; -import { Block as CRDTBlock } from "@noctaCrdt/Node"; +import { AnimationType, ElementType, TextStyleType } from "@noctaCrdt/Interfaces"; +import { Block as CRDTBlock, Char } from "@noctaCrdt/Node"; import { BlockId } from "@noctaCrdt/NodeId"; import { motion } from "framer-motion"; -import { memo, useRef } from "react"; +import { memo, useRef, useState } from "react"; +import { useModal } from "@src/components/modal/useModal"; import { useBlockAnimation } from "../../hooks/useBlockAnimtaion"; import { IconBlock } from "../IconBlock/IconBlock"; import { MenuBlock } from "../MenuBlock/MenuBlock"; +import { TextOptionModal } from "../TextOptionModal/TextOptionModal"; import { blockAnimation } from "./Block.animation"; import { textContainerStyle, blockContainerStyle, contentWrapperStyle } from "./Block.style"; @@ -22,8 +24,8 @@ interface BlockProps { onTypeSelect: (blockId: BlockId, type: ElementType) => void; onCopySelect: (blockId: BlockId) => void; onDeleteSelect: (blockId: BlockId) => void; + onTextStyleUpdate: (styleType: TextStyleType, blockId: BlockId, nodes: Array) => void; } - export const Block: React.FC = memo( ({ id, @@ -36,11 +38,12 @@ export const Block: React.FC = memo( onTypeSelect, onCopySelect, onDeleteSelect, + onTextStyleUpdate, }: BlockProps) => { - console.log("블록 초기화 상태", block); const blockRef = useRef(null); const blockCRDTRef = useRef(block); - + const { isOpen, openModal, closeModal } = useModal(); + const [selectedNodes, setSelectedNodes] = useState | null>(null); const { isAnimationStart } = useBlockAnimation(blockRef); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, @@ -70,6 +73,39 @@ export const Block: React.FC = memo( onDeleteSelect(block.id); }; + const handleMouseUp = () => { + const selection = window.getSelection(); + if (!selection || selection.isCollapsed || !blockRef.current) { + setSelectedNodes(null); + closeModal(); + return; + } + + const range = selection.getRangeAt(0); + if (!blockRef.current.contains(range.commonAncestorContainer)) { + setSelectedNodes(null); + closeModal(); + return; + } + + const nodes = blockCRDTRef.current.crdt.LinkedList.getNodesBetween( + range.startOffset, + range.endOffset, + ); + + if (nodes.length > 0) { + setSelectedNodes(nodes); + openModal(); + } + }; + + const handleStyleSelect = (styleType: TextStyleType) => { + if (selectedNodes) { + onTextStyleUpdate(styleType, block.id, selectedNodes); + closeModal(); + } + }; + return ( // TODO: eslint 규칙을 수정해야 할까? // TODO: ol일때 index 순서 처리 @@ -103,6 +139,7 @@ export const Block: React.FC = memo( onKeyDown={onKeyDown} onInput={handleInput} onClick={() => onClick(block.id)} + onMouseUp={handleMouseUp} contentEditable suppressContentEditableWarning className={textContainerStyle({ @@ -112,6 +149,14 @@ export const Block: React.FC = memo( {blockCRDTRef.current.crdt.read()} + handleStyleSelect("bold")} + onItalicSelect={() => handleStyleSelect("italic")} + onUnderlineSelect={() => handleStyleSelect("underline")} + onStrikeSelect={() => handleStyleSelect("strikethrough")} + /> ); }, From ea97d1a35f04ec986dd73cc84b01177641634f01 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sun, 24 Nov 2024 21:33:44 +0900 Subject: [PATCH 11/47] =?UTF-8?q?feat:=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존에는 read를 통해 일반 텍스트로 모두 변경 - 스타일 태그 적용을 위해 배열을 순회하며 스타일 속성이 있는 글자는 스타일 태그로 묶어서 innerhtml로 생성 --- .../src/features/editor/utils/domSyncUtils.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 client/src/features/editor/utils/domSyncUtils.ts diff --git a/client/src/features/editor/utils/domSyncUtils.ts b/client/src/features/editor/utils/domSyncUtils.ts new file mode 100644 index 00000000..c04aea7f --- /dev/null +++ b/client/src/features/editor/utils/domSyncUtils.ts @@ -0,0 +1,105 @@ +import { Block } from "@noctaCrdt/Node"; + +interface SetInnerHTMLProps { + element: HTMLDivElement; + block: Block; +} + +const getHtmlTag = (style: string): string => { + const tagMappings: Record = { + bold: "b", // bold는 + italic: "i", // italic은 + underline: "u", // underline은 + strikethrough: "s", // strikethrough는 + }; + return tagMappings[style] || "span"; // 기본은 +}; + +export const setInnerHTML = ({ element, block }: SetInnerHTMLProps): void => { + const chars = block.crdt.LinkedList.spread(); + if (chars.length === 0) { + element.innerHTML = ""; + return; + } + + // 각 위치별 모든 적용된 스타일을 추적 + const positionStyles: Set[] = chars.map((char, index) => { + const styleSet = new Set(); + + // 해당 위치에 적용되어야 하는 모든 스타일 수집 + chars.forEach((c, i) => { + if (i === index) { + c.style.forEach((style) => styleSet.add(style)); + } + }); + + return styleSet; + }); + + let html = ""; + let currentStyles = new Set(); + + chars.forEach((char, index) => { + const targetStyles = positionStyles[index]; + + // 제거해야 할 스타일 (현재는 있지만 다음에는 필요 없는 스타일) + const stylesToRemove = [...currentStyles].filter((style) => !targetStyles.has(style)); + + // 추가해야 할 스타일 (현재는 없지만 다음에는 필요한 스타일) + const stylesToAdd = [...targetStyles].filter((style) => !currentStyles.has(style)); + + // 제거할 스타일 태그 닫기 (역순으로) + stylesToRemove.reverse().forEach((style) => { + html += ``; + }); + + // 새로운 스타일 태그 열기 + stylesToAdd.forEach((style) => { + html += `<${getHtmlTag(style)}>`; + }); + + // 텍스트 추가 + html += sanitizeText(char.value); + + // 다음 문자로 넘어가기 전에 현재 스타일 상태 업데이트 + currentStyles = new Set(targetStyles); + + // 마지막 문자이거나 다음 문자와 스타일이 다른 경우 + if (index === chars.length - 1 || !setsEqual(targetStyles, positionStyles[index + 1])) { + // 현재 열려있는 모든 태그 닫기 + [...currentStyles].reverse().forEach((style) => { + html += ``; + }); + currentStyles.clear(); + } + }); + + // DOM 업데이트 + if (element.innerHTML !== html) { + element.innerHTML = html; + } +}; + +// Set 비교 헬퍼 함수 +const setsEqual = (a: Set, b: Set): boolean => { + if (a.size !== b.size) return false; + for (const item of a) { + if (!b.has(item)) return false; + } + return true; +}; + +const sanitizeText = (text: string): string => { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +}; + +// 배열 비교 헬퍼 함수 +export const arraysEqual = (a: string[], b: string[]): boolean => { + if (a.length !== b.length) return false; + return a.sort().every((val, idx) => val === b.sort()[idx]); +}; From a70d4308c91dc7fbef5804c014f75612d7fe74f7 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sun, 24 Nov 2024 21:34:41 +0900 Subject: [PATCH 12/47] =?UTF-8?q?fix:=20currentBlock=EA=B3=BC=20currentCar?= =?UTF-8?q?et=20=EC=97=90=EB=94=94=ED=84=B0=EC=97=90=EC=84=9C=20=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20=EC=9E=85=EB=A0=A5=ED=95=A0=EB=95=8C=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/editor/Editor.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index 7f9edbdb..320608a5 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -42,6 +42,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr sendBlockInsertOperation, sendBlockDeleteOperation, sendBlockUpdateOperation, + sendCharUpdateOperation, } = useSocketStore(); const editorCRDTInstance = useMemo(() => { const editor = new EditorCRDT(serializedEditorData.client); @@ -89,7 +90,6 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr editorCRDT: editorCRDT.current, setEditorState, pageId, - sendBlockUpdateOperation, }); const handleTitleChange = (e: React.ChangeEvent) => { @@ -111,6 +111,9 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr const selection = window.getSelection(); const caretPosition = selection?.focusOffset || 0; + editorCRDT.current.currentBlock = block; + editorCRDT.current.currentBlock.crdt.currentCaret = caretPosition; + if (newContent.length > currentContent.length) { let charNode: RemoteCharInsertOperation; if (caretPosition === 0) { @@ -133,6 +136,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr sendCharDeleteOperation(operationNode); editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; } + // syncDOMToCRDT(element, block.id); setEditorState({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, @@ -151,7 +155,10 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr position: editorCRDT.current.currentBlock?.crdt.currentCaret, }); // 서윤님 피드백 반영 - }, [editorCRDT.current.currentBlock?.id.serialize()]); + }, [ + editorCRDT.current.currentBlock?.id.serialize(), + editorCRDT.current.currentBlock?.crdt.currentCaret, + ]); useEffect(() => { if (subscriptionRef.current) return; @@ -250,6 +257,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr // 로컬 삽입을 수행하고 연산 객체를 반환받음 const operation = editorCRDT.current.localInsert(index, ""); + editorCRDT.current.currentBlock = operation.node; sendBlockInsertOperation({ node: operation.node, pageId }); setEditorState({ clock: operation.node.id.clock, From 90d0e2d2c7157ab0b840baaf1e38f614c7559dd2 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sun, 24 Nov 2024 21:36:00 +0900 Subject: [PATCH 13/47] =?UTF-8?q?feat:=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B0=90=EC=A7=80=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getTextOffset 함수를 통해 드래그한 텍스트가 스타일이 적용되어있어도 감지 - setInnerhtml을 텍스트가 변경될때마다 실행해서 실제 텍스트 렌더링 --- .../editor/components/block/Block.tsx | 55 +++++++++++++++---- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index cc75de0c..5476a3fe 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -4,9 +4,11 @@ import { AnimationType, ElementType, TextStyleType } from "@noctaCrdt/Interfaces import { Block as CRDTBlock, Char } from "@noctaCrdt/Node"; import { BlockId } from "@noctaCrdt/NodeId"; import { motion } from "framer-motion"; -import { memo, useRef, useState } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { useModal } from "@src/components/modal/useModal"; +import { setCaretPosition, getAbsoluteCaretPosition } from "@src/utils/caretUtils"; import { useBlockAnimation } from "../../hooks/useBlockAnimtaion"; +import { setInnerHTML } from "../../utils/domSyncUtils"; import { IconBlock } from "../IconBlock/IconBlock"; import { MenuBlock } from "../MenuBlock/MenuBlock"; import { TextOptionModal } from "../TextOptionModal/TextOptionModal"; @@ -24,7 +26,11 @@ interface BlockProps { onTypeSelect: (blockId: BlockId, type: ElementType) => void; onCopySelect: (blockId: BlockId) => void; onDeleteSelect: (blockId: BlockId) => void; - onTextStyleUpdate: (styleType: TextStyleType, blockId: BlockId, nodes: Array) => void; + onTextStyleUpdate: ( + styleType: TextStyleType, + blockId: BlockId, + nodes: Array | null, + ) => void; } export const Block: React.FC = memo( ({ @@ -88,11 +94,29 @@ export const Block: React.FC = memo( return; } - const nodes = blockCRDTRef.current.crdt.LinkedList.getNodesBetween( - range.startOffset, - range.endOffset, - ); + // 실제 텍스트 위치 계산 + const getTextOffset = (container: Node, offset: number): number => { + let totalOffset = 0; + const walker = document.createTreeWalker(blockRef.current!, NodeFilter.SHOW_TEXT, null); + let node = walker.nextNode(); + while (node) { + if (node === container) { + return totalOffset + offset; + } + if (node.compareDocumentPosition(container) & Node.DOCUMENT_POSITION_FOLLOWING) { + totalOffset += node.textContent?.length || 0; + } + node = walker.nextNode(); + } + return totalOffset; + }; + + const startOffset = getTextOffset(range.startContainer, range.startOffset); + const endOffset = getTextOffset(range.endContainer, range.endOffset); + + const nodes = block.crdt.LinkedList.spread().slice(startOffset, endOffset); + console.log("nodes", nodes); if (nodes.length > 0) { setSelectedNodes(nodes); openModal(); @@ -100,12 +124,24 @@ export const Block: React.FC = memo( }; const handleStyleSelect = (styleType: TextStyleType) => { - if (selectedNodes) { + if (blockRef.current && selectedNodes) { + const selection = window.getSelection(); + // CRDT 상태 업데이트 및 서버 전송 onTextStyleUpdate(styleType, block.id, selectedNodes); + + const position = selection?.focusOffset || 0; + block.crdt.currentCaret = position; + closeModal(); } }; + useEffect(() => { + if (blockRef.current) { + setInnerHTML({ element: blockRef.current, block }); + } + }, [block.crdt.serialize(), isActive]); + return ( // TODO: eslint 규칙을 수정해야 할까? // TODO: ol일때 index 순서 처리 @@ -141,13 +177,12 @@ export const Block: React.FC = memo( onClick={() => onClick(block.id)} onMouseUp={handleMouseUp} contentEditable + spellCheck={false} suppressContentEditableWarning className={textContainerStyle({ type: block.type, })} - > - {blockCRDTRef.current.crdt.read()} - + /> Date: Sun, 24 Nov 2024 21:36:17 +0900 Subject: [PATCH 14/47] =?UTF-8?q?design:=20=EA=B8=B0=EB=B3=B8=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=20=EA=B5=B5=EA=B8=B0=20normal=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/editor/components/block/Block.style.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/features/editor/components/block/Block.style.ts b/client/src/features/editor/components/block/Block.style.ts index 7a744530..987ab720 100644 --- a/client/src/features/editor/components/block/Block.style.ts +++ b/client/src/features/editor/components/block/Block.style.ts @@ -68,28 +68,28 @@ export const textContainerStyle = cva({ type: { p: { textStyle: "display-medium16", - fontWeight: "bold", + fontWeight: "normal", "&:empty::before": { content: '"텍스트를 입력하세요..."', }, }, h1: { textStyle: "display-medium24", - fontWeight: "bold", + fontWeight: "normal", "&:empty::before": { content: '"제목 1"', }, }, h2: { textStyle: "display-medium20", - fontWeight: "bold", + fontWeight: "normal", "&:empty::before": { content: '"제목 2"', }, }, h3: { textStyle: "display-medium16", - fontWeight: "bold", + fontWeight: "normal", "&:empty::before": { content: '"제목 3"', }, From d646a1f20536acd86d11141f831d14fe290f2b34 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sun, 24 Nov 2024 21:36:37 +0900 Subject: [PATCH 15/47] =?UTF-8?q?fix:=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/editor/hooks/useTextOptions.ts | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/client/src/features/editor/hooks/useTextOptions.ts b/client/src/features/editor/hooks/useTextOptions.ts index a86d8b87..1c2fa9e6 100644 --- a/client/src/features/editor/hooks/useTextOptions.ts +++ b/client/src/features/editor/hooks/useTextOptions.ts @@ -10,64 +10,68 @@ interface UseTextOptionSelectProps { editorCRDT: EditorCRDT; setEditorState: React.Dispatch>; pageId: string; - sendBlockUpdateOperation: (operation: RemoteBlockUpdateOperation) => void; } export const useTextOptionSelect = ({ editorCRDT, setEditorState, pageId, - sendBlockUpdateOperation, }: UseTextOptionSelectProps) => { const { sendCharUpdateOperation } = useSocketStore(); const handleStyleUpdate = useCallback( - (styleType: TextStyleType, blockId: BlockId, nodes: Array) => { - if (!nodes) return; + (styleType: TextStyleType, blockId: BlockId, nodes: Array | null) => { + if (!nodes || nodes.length === 0) return; + const block = editorCRDT.LinkedList.getNode(blockId) as Block; + if (!block) return; + + // 선택된 범위의 모든 문자들의 현재 스타일 상태 확인 + const hasStyle = nodes.every((node) => { + const char = block.crdt.LinkedList.getNode(node.id) as Char; + return char && char.style.includes(styleType); + }); + + // 스타일을 추가할지 제거할지 결정 (토글 로직) + const shouldAdd = !hasStyle; nodes.forEach((node) => { - const block = editorCRDT.LinkedList.getNode(blockId) as Block; - if (!block) return; const char = block.crdt.LinkedList.getNode(node.id) as Char; if (!char) return; - const toggleStyle = (style: TextStyleType) => { - if (char.style.includes(style)) { - char.style = char.style.filter((s) => s !== style); - } else { - char.style.push(style); - } - }; + // 현재 스타일 복사 + const newStyles = [...char.style]; - switch (styleType) { - case "bold": - toggleStyle("bold"); - break; - case "italic": - toggleStyle("italic"); - break; - case "underline": - toggleStyle("underline"); - break; - case "strikethrough": - toggleStyle("strikethrough"); - break; + if (shouldAdd) { + // 스타일이 없는 경우에만 추가 + if (!newStyles.includes(styleType)) { + newStyles.push(styleType); + } + } else { + // 스타일이 있는 경우에만 제거 + const styleIndex = newStyles.indexOf(styleType); + if (styleIndex !== -1) { + newStyles.splice(styleIndex, 1); + } } - block.crdt.localUpdate(char, node.id, pageId); + // 새로운 스타일 배열 할당 + char.style = newStyles; + // 업데이트 및 전송 + block.crdt.localUpdate(char, node.id, pageId); sendCharUpdateOperation({ node: char, blockId, pageId, }); }); + setEditorState({ clock: editorCRDT.clock, linkedList: editorCRDT.LinkedList, }); }, - [pageId, sendBlockUpdateOperation], + [pageId, sendCharUpdateOperation, editorCRDT], ); return { From dfc4d59f80701d0da168786e693d44c9eaff4781 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 03:54:37 +0900 Subject: [PATCH 16/47] =?UTF-8?q?feat:=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=EC=9E=88=EB=8A=94=20=EA=B2=BD=EC=9A=B0=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- @noctaCrdt/Crdt.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/@noctaCrdt/Crdt.ts b/@noctaCrdt/Crdt.ts index 0a32a275..c30a0d66 100644 --- a/@noctaCrdt/Crdt.ts +++ b/@noctaCrdt/Crdt.ts @@ -218,9 +218,13 @@ export class BlockCRDT extends CRDT { value: string, blockId: BlockId, pageId: string, + style?: string[], ): RemoteCharInsertOperation { const id = new CharId(this.clock + 1, this.client); const { node } = this.LinkedList.insertAtIndex(index, value, id) as { node: Char }; + if (style && style.length > 0) { + node.style = style; + } this.clock += 1; const operation: RemoteCharInsertOperation = { node, From 14cc2f03f393982dc378a213d5686be73d9741bf Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 03:55:28 +0900 Subject: [PATCH 17/47] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/editor/utils/domSyncUtils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/features/editor/utils/domSyncUtils.ts b/client/src/features/editor/utils/domSyncUtils.ts index c04aea7f..e2cbc2d1 100644 --- a/client/src/features/editor/utils/domSyncUtils.ts +++ b/client/src/features/editor/utils/domSyncUtils.ts @@ -23,7 +23,7 @@ export const setInnerHTML = ({ element, block }: SetInnerHTMLProps): void => { } // 각 위치별 모든 적용된 스타일을 추적 - const positionStyles: Set[] = chars.map((char, index) => { + const positionStyles: Set[] = chars.map((_, index) => { const styleSet = new Set(); // 해당 위치에 적용되어야 하는 모든 스타일 수집 @@ -91,6 +91,7 @@ const setsEqual = (a: Set, b: Set): boolean => { const sanitizeText = (text: string): string => { return text + .replace(/
/g, " ") .replace(/&/g, "&") .replace(//g, ">") From 821447532b2bb5a0a15e54af856fd3af63614437 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 03:56:00 +0900 Subject: [PATCH 18/47] =?UTF-8?q?feat:=20=EC=BA=90=EB=9F=BF=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=A9=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/editor/Editor.tsx | 36 +++++++++++-------- .../editor/components/block/Block.tsx | 14 +++++--- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index 320608a5..4bc94ac2 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -10,7 +10,7 @@ import { } from "node_modules/@noctaCrdt/Interfaces.ts"; import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from "react"; import { useSocketStore } from "@src/stores/useSocketStore.ts"; -import { setCaretPosition } from "@src/utils/caretUtils.ts"; +import { setCaretPosition, getAbsoluteCaretPosition } from "@src/utils/caretUtils.ts"; import { editorContainer, editorTitleContainer, @@ -42,7 +42,6 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr sendBlockInsertOperation, sendBlockDeleteOperation, sendBlockUpdateOperation, - sendCharUpdateOperation, } = useSocketStore(); const editorCRDTInstance = useMemo(() => { const editor = new EditorCRDT(serializedEditorData.client); @@ -96,9 +95,21 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onTitleChange(e.target.value); }; - const handleBlockClick = (blockId: BlockId) => { + const handleBlockClick = (blockId: BlockId, e: React.MouseEvent) => { + const selection = window.getSelection(); + if (!selection) return; + + const clickedElement = (e.target as HTMLElement).closest( + '[contenteditable="true"]', + ) as HTMLDivElement; + if (!clickedElement) return; + editorCRDT.current.currentBlock = editorCRDT.current.LinkedList.nodeMap[JSON.stringify(blockId)]; + const caretPosition = getAbsoluteCaretPosition(clickedElement); + + // 계산된 캐럿 위치 저장 + editorCRDT.current.currentBlock.crdt.currentCaret = caretPosition; }; const handleBlockInput = useCallback( @@ -108,11 +119,8 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr const element = e.currentTarget; const newContent = element.textContent || ""; const currentContent = block.crdt.read(); - const selection = window.getSelection(); - const caretPosition = selection?.focusOffset || 0; - - editorCRDT.current.currentBlock = block; - editorCRDT.current.currentBlock.crdt.currentCaret = caretPosition; + const caretPosition = getAbsoluteCaretPosition(element); + console.log(caretPosition, "블록의 캐럿 위치"); if (newContent.length > currentContent.length) { let charNode: RemoteCharInsertOperation; @@ -134,9 +142,9 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr // 문자가 삭제된 경우 operationNode = block.crdt.localDelete(caretPosition, block.id, pageId); sendCharDeleteOperation(operationNode); - editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; + // editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; + editorCRDT.current.currentBlock!.crdt.currentCaret -= 1; } - // syncDOMToCRDT(element, block.id); setEditorState({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, @@ -147,18 +155,16 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr const subscriptionRef = useRef(false); - useLayoutEffect(() => { + useEffect(() => { if (!editorCRDT.current.currentBlock) return; + // TODO: 값이 제대로 들어왔는데 왜 안되는지 확인 필요 setCaretPosition({ blockId: editorCRDT.current.currentBlock.id, linkedList: editorCRDT.current.LinkedList, position: editorCRDT.current.currentBlock?.crdt.currentCaret, }); // 서윤님 피드백 반영 - }, [ - editorCRDT.current.currentBlock?.id.serialize(), - editorCRDT.current.currentBlock?.crdt.currentCaret, - ]); + }, [editorCRDT.current.currentBlock?.id.serialize()]); useEffect(() => { if (subscriptionRef.current) return; diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index 5476a3fe..48dd112b 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -21,7 +21,7 @@ interface BlockProps { isActive: boolean; onInput: (e: React.FormEvent, block: CRDTBlock) => void; onKeyDown: (e: React.KeyboardEvent) => void; - onClick: (blockId: BlockId) => void; + onClick: (blockId: BlockId, e: React.MouseEvent) => void; onAnimationSelect: (blockId: BlockId, animation: AnimationType) => void; onTypeSelect: (blockId: BlockId, type: ElementType) => void; onCopySelect: (blockId: BlockId) => void; @@ -47,7 +47,6 @@ export const Block: React.FC = memo( onTextStyleUpdate, }: BlockProps) => { const blockRef = useRef(null); - const blockCRDTRef = useRef(block); const { isOpen, openModal, closeModal } = useModal(); const [selectedNodes, setSelectedNodes] = useState | null>(null); const { isAnimationStart } = useBlockAnimation(blockRef); @@ -60,6 +59,13 @@ export const Block: React.FC = memo( }); const handleInput = (e: React.FormEvent) => { + // 텍스트를 삭제하면
태그가 생김 + // 이를 방지하기 위해
태그 찾아서 모두 삭제 + const brElements = e.currentTarget.getElementsByTagName("br"); + if (brElements.length > 0) { + e.preventDefault(); + Array.from(brElements).forEach((br) => br.remove()); + } onInput(e, block); }; @@ -140,7 +146,7 @@ export const Block: React.FC = memo( if (blockRef.current) { setInnerHTML({ element: blockRef.current, block }); } - }, [block.crdt.serialize(), isActive]); + }, [block.crdt.serialize()]); return ( // TODO: eslint 규칙을 수정해야 할까? @@ -174,7 +180,7 @@ export const Block: React.FC = memo( ref={blockRef} onKeyDown={onKeyDown} onInput={handleInput} - onClick={() => onClick(block.id)} + onClick={(e) => onClick(block.id, e)} onMouseUp={handleMouseUp} contentEditable spellCheck={false} From 7100ac3b1b61b860fc508bbed220c68bdd5a1f17 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 03:56:14 +0900 Subject: [PATCH 19/47] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20console.log=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/stores/useSocketStore.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/src/stores/useSocketStore.ts b/client/src/stores/useSocketStore.ts index 7673e0cd..c9ca31f7 100644 --- a/client/src/stores/useSocketStore.ts +++ b/client/src/stores/useSocketStore.ts @@ -121,14 +121,11 @@ export const useSocketStore = create((set, get) => ({ sendBlockInsertOperation: (operation: RemoteBlockInsertOperation) => { const { socket } = get(); - console.log("block insert operation", operation); - console.log("socket", socket); socket?.emit("insert/block", operation); }, sendCharInsertOperation: (operation: RemoteCharInsertOperation) => { const { socket } = get(); - console.log("char insert operation", operation); socket?.emit("insert/char", operation); }, From b1b83f8fcab98217a383dabe2434b0ab62393e8a Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 03:56:40 +0900 Subject: [PATCH 20/47] =?UTF-8?q?=EC=A7=84=ED=96=89=EC=83=81=ED=99=A9=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/hooks/useMarkdownGrammer.ts | 291 +++++++++++++++++- client/src/utils/caretUtils.ts | 209 ++++++++++--- 2 files changed, 450 insertions(+), 50 deletions(-) diff --git a/client/src/features/editor/hooks/useMarkdownGrammer.ts b/client/src/features/editor/hooks/useMarkdownGrammer.ts index 3a1308f7..d36ff3d5 100644 --- a/client/src/features/editor/hooks/useMarkdownGrammer.ts +++ b/client/src/features/editor/hooks/useMarkdownGrammer.ts @@ -7,11 +7,10 @@ import { RemoteCharDeleteOperation, } from "@noctaCrdt/Interfaces"; import { BlockLinkedList } from "@noctaCrdt/LinkedList"; -import { BlockId } from "@noctaCrdt/NodeId"; import { useCallback } from "react"; import { EditorStateProps } from "@features/editor/Editor"; import { checkMarkdownPattern } from "@src/features/editor/utils/markdownPatterns"; -import { setCaretPosition } from "@src/utils/caretUtils"; +import { setCaretPosition, getAbsoluteCaretPosition } from "@src/utils/caretUtils"; interface useMarkdownGrammerProps { editorCRDT: EditorCRDT; @@ -50,13 +49,7 @@ export const useMarkdownGrammer = ({ return operation; }; - const updateEditorState = (newBlockId: BlockId | null = null) => { - if ( - newBlockId !== null && - editorCRDT.currentBlock !== editorCRDT.LinkedList.getNode(newBlockId) - ) { - editorCRDT.currentBlock = editorCRDT.LinkedList.getNode(newBlockId); - } + const updateEditorState = () => { setEditorState({ clock: editorCRDT.clock, linkedList: editorCRDT.LinkedList, @@ -74,6 +67,279 @@ export const useMarkdownGrammer = ({ ); switch (e.key) { + case "Enter": { + e.preventDefault(); + const caretPosition = getAbsoluteCaretPosition(e.currentTarget); + const currentContent = currentBlock.crdt.read(); + + if (!currentContent && currentBlock.type !== "p") { + currentBlock.type = "p"; + sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); + editorCRDT.currentBlock = currentBlock; + editorCRDT.currentBlock.crdt.currentCaret = 0; + updateEditorState(); + break; + } + + if (!currentContent && currentBlock.type === "p") { + // 새로운 기본 블록 생성 + const operation = createNewBlock(currentIndex + 1); + operation.node.indent = currentBlock.indent; + operation.node.crdt = new BlockCRDT(editorCRDT.client); + + sendBlockInsertOperation({ node: operation.node, pageId }); + editorCRDT.currentBlock = operation.node; + editorCRDT.currentBlock.crdt.currentCaret = 0; + updateEditorState(); + break; + } + + // 현재 캐럿 위치를 기준으로 내용 분할 + const afterContent = currentContent.slice(caretPosition); + + // 새 블록 생성 + const operation = createNewBlock(currentIndex + 1); + operation.node.crdt = new BlockCRDT(editorCRDT.client); + operation.node.indent = currentBlock.indent; + sendBlockInsertOperation({ node: operation.node, pageId }); + + // 캐럿 이후의 텍스트 있으면 새 블록에 추가 + if (afterContent) { + afterContent.split("").forEach((char, i) => { + sendCharInsertOperation( + operation.node.crdt.localInsert(i, char, operation.node.id, pageId), + ); + }); + for (let i = currentContent.length - 1; i >= caretPosition; i--) { + sendCharDeleteOperation(currentBlock.crdt.localDelete(i, currentBlock.id, pageId)); + } + } + + // 현재 블록이 li나 checkbox면 동일한 타입으로 생성 + if (["ul", "ol", "checkbox"].includes(currentBlock.type)) { + operation.node.type = currentBlock.type; + sendBlockUpdateOperation(editorCRDT.localUpdate(operation.node, pageId)); + } + + editorCRDT.currentBlock = operation.node; + editorCRDT.currentBlock.crdt.currentCaret = 0; + updateEditorState(); + break; + } + + case "Backspace": { + const currentContent = currentBlock.crdt.read(); + if (currentContent === "") { + e.preventDefault(); + if (currentBlock.indent > 0) { + currentBlock.indent -= 1; + sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); + editorCRDT.currentBlock = currentBlock; + updateEditorState(); + break; + } + + if (currentBlock.type !== "p") { + // 마지막 블록이면 기본 블록으로 변경 + currentBlock.type = "p"; + sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); + editorCRDT.currentBlock = currentBlock; + updateEditorState(); + break; + } + + const prevBlock = + currentIndex > 0 ? editorCRDT.LinkedList.findByIndex(currentIndex - 1) : null; + if (prevBlock) { + sendBlockDeleteOperation(editorCRDT.localDelete(currentIndex, undefined, pageId)); + prevBlock.crdt.currentCaret = prevBlock.crdt.spread().length; + editorCRDT.currentBlock = prevBlock; + updateEditorState(); + } + break; + } else { + const currentCaretPosition = currentBlock.crdt.currentCaret; + if (currentCaretPosition === 0) { + if (currentBlock.indent > 0) { + currentBlock.indent -= 1; + sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); + editorCRDT.currentBlock = currentBlock; + updateEditorState(); + break; + } + if (currentBlock.type !== "p") { + currentBlock.type = "p"; + sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); + editorCRDT.currentBlock = currentBlock; + updateEditorState(); + break; + } + // FIX: 서윤님 피드백 반영 + const prevBlock = + currentIndex > 0 ? editorCRDT.LinkedList.findByIndex(currentIndex - 1) : null; + if (prevBlock) { + const prevBlockEndCaret = prevBlock.crdt.spread().length; + currentContent.split("").forEach((char) => { + sendCharInsertOperation( + prevBlock.crdt.localInsert( + prevBlock.crdt.read().length, + char, + prevBlock.id, + pageId, + ), + ); + sendCharDeleteOperation( + currentBlock.crdt.localDelete(0, currentBlock.id, pageId), + ); + }); + editorCRDT.currentBlock = prevBlock; + editorCRDT.currentBlock.crdt.currentCaret = prevBlockEndCaret; + sendBlockDeleteOperation(editorCRDT.localDelete(currentIndex, undefined, pageId)); + updateEditorState(); + e.preventDefault(); + } + } + break; + } + } + + case "Tab": { + e.preventDefault(); + + if (currentBlock) { + if (e.shiftKey) { + // shift + tab: 들여쓰기 감소 + if (currentBlock.indent > 0) { + currentBlock.indent -= 1; + sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); + editorCRDT.currentBlock = currentBlock; + updateEditorState(); + } + } else { + // tab: 들여쓰기 증가 + const maxIndent = 3; + if (currentBlock.indent < maxIndent) { + currentBlock.indent += 1; + sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); + editorCRDT.currentBlock = currentBlock; + updateEditorState(); + } + } + } + break; + } + + case " ": { + // 여기 수정함 + const selection = window.getSelection(); + if (!selection) return; + const currentContent = currentBlock.crdt.read(); + const markdownElement = checkMarkdownPattern(currentContent); + if (markdownElement && currentBlock.type === "p") { + e.preventDefault(); + // 마크다운 패턴 매칭 시 타입 변경하고 내용 비우기 + currentBlock.type = markdownElement.type; + for (let i = 0; i < markdownElement.length; i++) { + sendCharDeleteOperation(currentBlock.crdt.localDelete(0, currentBlock.id, pageId)); + } + sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); + currentBlock.crdt.currentCaret = 0; + editorCRDT.currentBlock = currentBlock; + updateEditorState(); + } + + break; + } + + case "ArrowUp": + case "ArrowDown": { + const hasPrevBlock = currentIndex > 0; + const hasNextBlock = currentIndex < editorCRDT.LinkedList.spread().length - 1; + if (e.key === "ArrowUp" && !hasPrevBlock) { + e.preventDefault(); + return; + } + if (e.key === "ArrowDown" && !hasNextBlock) { + e.preventDefault(); + return; + } + + const selection = window.getSelection(); + const caretPosition = selection?.focusOffset || 0; + + // 이동할 블록 결정 + const targetIndex = e.key === "ArrowUp" ? currentIndex - 1 : currentIndex + 1; + const targetBlock = editorCRDT.LinkedList.findByIndex(targetIndex); + if (!targetBlock) return; + e.preventDefault(); + targetBlock.crdt.currentCaret = Math.min(caretPosition, targetBlock.crdt.read().length); + editorCRDT.currentBlock = targetBlock; + setCaretPosition({ + blockId: targetBlock.id, + linkedList: editorCRDT.LinkedList, + position: Math.min(caretPosition, targetBlock.crdt.read().length), + }); + break; + } + case "ArrowLeft": + case "ArrowRight": { + const selection = window.getSelection(); + const caretPosition = selection?.focusOffset || 0; + const textLength = currentBlock.crdt.read().length; + + // 왼쪽 끝에서 이전 블록으로 + if (e.key === "ArrowLeft" && caretPosition === 0 && currentIndex > 0) { + e.preventDefault(); // 기본 동작 방지 + const prevBlock = editorCRDT.LinkedList.findByIndex(currentIndex - 1); + if (prevBlock) { + prevBlock.crdt.currentCaret = prevBlock.crdt.read().length; + editorCRDT.currentBlock = prevBlock; + setCaretPosition({ + blockId: prevBlock.id, + linkedList: editorCRDT.LinkedList, + position: prevBlock.crdt.read().length, + }); + } + break; + // 오른쪽 끝에서 다음 블록으로 + } else if ( + e.key === "ArrowRight" && + caretPosition === textLength && + currentIndex < editorCRDT.LinkedList.spread().length - 1 + ) { + e.preventDefault(); // 기본 동작 방지 + const nextBlock = editorState.linkedList.findByIndex(currentIndex + 1); + if (nextBlock) { + nextBlock.crdt.currentCaret = 0; + editorCRDT.currentBlock = nextBlock; + setCaretPosition({ + blockId: nextBlock.id, + linkedList: editorCRDT.LinkedList, + position: 0, + }); + } + break; + // 블록 내에서 이동하는 경우 + } else { + if (e.key === "ArrowLeft") { + currentBlock.crdt.currentCaret -= 1; + } else { + currentBlock.crdt.currentCaret += 1; + } + } + + break; + } + } + }, + [editorCRDT, editorState, setEditorState, pageId], + ); + + return { handleKeyDown }; +}; + +/* +switch (e.key) { case "Enter": { e.preventDefault(); const selection = window.getSelection(); @@ -338,9 +604,4 @@ export const useMarkdownGrammer = ({ break; } } - }, - [editorCRDT, editorState, setEditorState, pageId], - ); - - return { handleKeyDown }; -}; + */ diff --git a/client/src/utils/caretUtils.ts b/client/src/utils/caretUtils.ts index 577a4015..43feba2a 100644 --- a/client/src/utils/caretUtils.ts +++ b/client/src/utils/caretUtils.ts @@ -4,68 +4,207 @@ import { BlockId } from "@noctaCrdt/NodeId"; interface SetCaretPositionProps { blockId: BlockId; linkedList: BlockLinkedList | TextLinkedList; - clientX?: number; - clientY?: number; position?: number; // 특정 위치로 캐럿을 설정하고 싶을 때 사용 } export const setCaretPosition = ({ blockId, linkedList, - clientX, - clientY, position, -}: SetCaretPositionProps): boolean => { +}: SetCaretPositionProps): void => { try { + if (position === undefined) return; const selection = window.getSelection(); - if (!selection) return false; + if (!selection) return; const blockElements = Array.from( document.querySelectorAll('.d_flex.pos_relative.w_full[data-group="true"]'), ); const currentIndex = linkedList.spread().findIndex((b) => b.id === blockId); const targetElement = blockElements[currentIndex]; - if (!targetElement) return false; + if (!targetElement) return; const editableDiv = targetElement.querySelector('[contenteditable="true"]') as HTMLDivElement; - if (!editableDiv) return false; + if (!editableDiv) return; editableDiv.focus(); - let range: Range; - - if (clientX !== undefined && clientY !== undefined) { - // 클릭 위치에 따른 캐럿 설정 - const clickRange = document.caretRangeFromPoint(clientX, clientY); - if (!clickRange) return false; - range = clickRange; - } else if (position !== undefined) { - // 특정 위치에 캐럿 설정 - range = document.createRange(); - const textNode = - Array.from(editableDiv.childNodes).find((node) => node.nodeType === Node.TEXT_NODE) || null; - if (!textNode) { - // 텍스트 노드가 없으면 새로운 텍스트 노드를 추가 - const newTextNode = document.createTextNode(""); - editableDiv.appendChild(newTextNode); - range.setStart(newTextNode, 0); - } else { - // position이 텍스트 길이를 초과하지 않도록 조정 - const safePosition = Math.min(position, textNode.textContent?.length || 0); - range.setStart(textNode, safePosition); + const range = document.createRange(); + const textNodes = Array.from(editableDiv.childNodes).filter( + (node) => node.nodeType === Node.TEXT_NODE, + ); + + let currentPosition = 0; + let targetNode: Node | null = null; + let targetOffset = 0; + + for (const node of textNodes) { + const textContent = node.textContent || ""; + if (currentPosition + textContent.length >= position) { + targetNode = node; + targetOffset = position - currentPosition; + break; } + currentPosition += textContent.length; + } - range.collapse(true); - } else { - return false; + if (!targetNode) { + targetNode = editableDiv; + targetOffset = editableDiv.childNodes.length; } + range.setStart(targetNode, targetOffset); + range.collapse(true); + selection.removeAllRanges(); selection.addRange(range); - - return true; } catch (error) { console.error("Error setting caret position:", error); - return false; + return; + } +}; + +/* +export const getAbsoluteCaretPosition = (element: HTMLElement): number => { + const selection = window.getSelection(); + if (!selection?.focusNode) return 0; + + // 스타일 태그 내부의 텍스트 노드도 포함하여 모든 텍스트 노드 수집 + const collectTextNodesAndOffsets = (root: Node): { node: Text; offset: number }[] => { + const nodes: { node: Text; offset: number }[] = []; + let currentOffset = 0; + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); + let node = walker.nextNode(); + + while (node) { + if (node.nodeType === Node.TEXT_NODE && node.nodeValue !== "\n") { + nodes.push({ + node: node as Text, + offset: currentOffset, + }); + currentOffset += node.nodeValue?.length || 0; + } + node = walker.nextNode(); + } + + return nodes; + }; + + const textNodesWithOffsets = collectTextNodesAndOffsets(element); + + // focusNode가 텍스트 노드인 경우 + if (selection.focusNode.nodeType === Node.TEXT_NODE) { + const focusTextNode = selection.focusNode as Text; + const nodeInfo = textNodesWithOffsets.find((info) => info.node === focusTextNode); + if (nodeInfo) { + return nodeInfo.offset + selection.focusOffset; + } + } + + // focusNode가 element인 경우 (모든 텍스트가 삭제된 경우) + if (selection.focusNode === element) { + if (textNodesWithOffsets.length === 0) { + return 0; + } + // 마지막 텍스트 노드의 끝 위치 반환 + const lastNode = textNodesWithOffsets[textNodesWithOffsets.length - 1]; + return lastNode.offset + lastNode.node.length; + } + + return 0; +}; +*/ + +export const getAbsoluteCaretPosition = (element: HTMLElement): number => { + const selection = window.getSelection(); + if (!selection?.focusNode) return 0; + + const collectTextNodesAndOffsets = ( + root: Node, + ): { + node: Text | Node; + offset: number; + length: number; + }[] => { + const nodes: { node: Text | Node; offset: number; length: number }[] = []; + let currentOffset = 0; + + // 모든 노드를 순회하면서 텍스트 노드와 그 부모 노드의 정보 수집 + const walker = document.createTreeWalker( + root, + NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, + null, + ); + + let node = walker.nextNode(); + while (node) { + if (node.nodeType === Node.TEXT_NODE && node.nodeValue !== "\n") { + nodes.push({ + node, + offset: currentOffset, + length: node.nodeValue?.length || 0, + }); + currentOffset += node.nodeValue?.length || 0; + } else if (node.nodeType === Node.ELEMENT_NODE) { + // 요소 노드도 추적 + nodes.push({ + node, + offset: currentOffset, + length: node.textContent?.length || 0, + }); + } + node = walker.nextNode(); + } + + return nodes; + }; + + const textNodesWithOffsets = collectTextNodesAndOffsets(element); + // focusNode가 텍스트 노드인 경우 + if (selection.focusNode.nodeType === Node.TEXT_NODE) { + const focusTextNode = selection.focusNode; + const nodeInfo = textNodesWithOffsets.find((info) => info.node === focusTextNode); + if (nodeInfo) { + return nodeInfo.offset + selection.focusOffset; + } + } + + // focusNode가 요소 노드인 경우 (스타일 태그 등) + if (selection.focusNode.nodeType === Node.ELEMENT_NODE) { + const focusElement = selection.focusNode; + + // 현재 요소의 모든 이전 텍스트 길이 계산 + let totalOffset = 0; + for (const nodeInfo of textNodesWithOffsets) { + if (nodeInfo.node === focusElement || focusElement.contains(nodeInfo.node)) { + if (nodeInfo.node === focusElement) { + return totalOffset + selection.focusOffset; + } + // focusOffset이 요소 내부의 특정 위치를 가리키는 경우 + if ( + selection.focusOffset > 0 && + nodeInfo.offset + nodeInfo.length >= selection.focusOffset + ) { + return nodeInfo.offset + selection.focusOffset; + } + } + totalOffset += nodeInfo.length; + } + return totalOffset; } + + // focusNode가 element 자체인 경우 (빈 블록이나 모든 텍스트가 삭제된 경우) + if (selection.focusNode === element) { + // 선택된 오프셋까지의 모든 텍스트 길이 합산 + let totalOffset = 0; + for (const nodeInfo of textNodesWithOffsets) { + if (nodeInfo.offset < selection.focusOffset) { + totalOffset += nodeInfo.length; + } + } + return totalOffset; + } + + return 0; }; From 685a161668e2bff9f0f958fccbb6c2126914e60c Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 12:41:58 +0900 Subject: [PATCH 21/47] =?UTF-8?q?feat:=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/styles/typography.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/client/src/styles/typography.ts b/client/src/styles/typography.ts index 39c70833..98b7d529 100644 --- a/client/src/styles/typography.ts +++ b/client/src/styles/typography.ts @@ -60,4 +60,30 @@ export const textStyles = defineTextStyles({ textTransform: "None", }, }, + + bold: { + value: { + fontWeight: "bold", + }, + }, + italic: { + value: { + fontStyle: "italic", + }, + }, + underline: { + value: { + textDecoration: "underline", + }, + }, + strikethrough: { + value: { + textDecoration: "line-through", + }, + }, + "underline-strikethrough": { + value: { + textDecoration: "underline line-through", + }, + }, }); From cb2cc39294f6c3c545725fb30471089a54b1ecbf Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 12:42:26 +0900 Subject: [PATCH 22/47] =?UTF-8?q?fix:=20span=EA=B8=B0=EB=B0=98=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=EB=A7=81=20=EB=B0=A9=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/utils/caretUtils.ts | 242 ++++++++++++--------------------- 1 file changed, 85 insertions(+), 157 deletions(-) diff --git a/client/src/utils/caretUtils.ts b/client/src/utils/caretUtils.ts index 43feba2a..06c0cda1 100644 --- a/client/src/utils/caretUtils.ts +++ b/client/src/utils/caretUtils.ts @@ -7,6 +7,68 @@ interface SetCaretPositionProps { position?: number; // 특정 위치로 캐럿을 설정하고 싶을 때 사용 } +export const getAbsoluteCaretPosition = (element: HTMLElement): number => { + const selection = window.getSelection(); + if (!selection?.focusNode) return 0; + + // 실제 텍스트 내용만 추출하여 위치를 계산하는 함수 + const collectTextNodes = (root: Node): Text[] => { + const nodes: Text[] = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode: (node) => { + // 실제 텍스트 노드만 수집 + if (node.nodeType === Node.TEXT_NODE && node.textContent) { + return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_SKIP; + }, + }); + + let node: Node | null; + while ((node = walker.nextNode())) { + nodes.push(node as Text); + } + return nodes; + }; + + // 모든 텍스트 노드 수집 + const textNodes = collectTextNodes(element); + + // focusNode가 텍스트 노드인 경우 + if (selection.focusNode.nodeType === Node.TEXT_NODE) { + let position = 0; + + for (const node of textNodes) { + if (node === selection.focusNode) { + return position + selection.focusOffset; + } + position += node.textContent?.length || 0; + } + } + + // focusNode가 element인 경우 + if (selection.focusNode === element) { + return selection.focusOffset; + } + + // focusNode가 span인 경우 + if (selection.focusNode.nodeType === Node.ELEMENT_NODE) { + let position = 0; + const focusElement = selection.focusNode; + + for (const node of textNodes) { + if (focusElement.contains(node)) { + if (position + (node.textContent?.length || 0) >= selection.focusOffset) { + return position + selection.focusOffset; + } + position += node.textContent?.length || 0; + } + } + } + + return 0; +}; + export const setCaretPosition = ({ blockId, linkedList, @@ -29,30 +91,42 @@ export const setCaretPosition = ({ editableDiv.focus(); - const range = document.createRange(); - const textNodes = Array.from(editableDiv.childNodes).filter( - (node) => node.nodeType === Node.TEXT_NODE, - ); - let currentPosition = 0; let targetNode: Node | null = null; let targetOffset = 0; - for (const node of textNodes) { - const textContent = node.textContent || ""; - if (currentPosition + textContent.length >= position) { + const walker = document.createTreeWalker(editableDiv, NodeFilter.SHOW_TEXT, { + acceptNode: (node): number => { + return node.nodeType === Node.TEXT_NODE && node.textContent !== "\n" + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_SKIP; + }, + }); + + let node: Node | null; + while ((node = walker.nextNode())) { + const length = node.textContent?.length || 0; + if (currentPosition + length >= position) { targetNode = node; targetOffset = position - currentPosition; break; } - currentPosition += textContent.length; + currentPosition += length; } if (!targetNode) { - targetNode = editableDiv; - targetOffset = editableDiv.childNodes.length; + // 마지막 텍스트 노드 찾기 + const textNodes = Array.from(editableDiv.querySelectorAll("*")) + .flatMap((element) => Array.from(element.childNodes)) + .filter((node): node is Text => node.nodeType === Node.TEXT_NODE); + + const lastTextNode = textNodes.length > 0 ? textNodes[textNodes.length - 1] : editableDiv; + + targetNode = lastTextNode; + targetOffset = lastTextNode.textContent?.length || 0; } + const range = document.createRange(); range.setStart(targetNode, targetOffset); range.collapse(true); @@ -60,151 +134,5 @@ export const setCaretPosition = ({ selection.addRange(range); } catch (error) { console.error("Error setting caret position:", error); - return; - } -}; - -/* -export const getAbsoluteCaretPosition = (element: HTMLElement): number => { - const selection = window.getSelection(); - if (!selection?.focusNode) return 0; - - // 스타일 태그 내부의 텍스트 노드도 포함하여 모든 텍스트 노드 수집 - const collectTextNodesAndOffsets = (root: Node): { node: Text; offset: number }[] => { - const nodes: { node: Text; offset: number }[] = []; - let currentOffset = 0; - - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); - let node = walker.nextNode(); - - while (node) { - if (node.nodeType === Node.TEXT_NODE && node.nodeValue !== "\n") { - nodes.push({ - node: node as Text, - offset: currentOffset, - }); - currentOffset += node.nodeValue?.length || 0; - } - node = walker.nextNode(); - } - - return nodes; - }; - - const textNodesWithOffsets = collectTextNodesAndOffsets(element); - - // focusNode가 텍스트 노드인 경우 - if (selection.focusNode.nodeType === Node.TEXT_NODE) { - const focusTextNode = selection.focusNode as Text; - const nodeInfo = textNodesWithOffsets.find((info) => info.node === focusTextNode); - if (nodeInfo) { - return nodeInfo.offset + selection.focusOffset; - } - } - - // focusNode가 element인 경우 (모든 텍스트가 삭제된 경우) - if (selection.focusNode === element) { - if (textNodesWithOffsets.length === 0) { - return 0; - } - // 마지막 텍스트 노드의 끝 위치 반환 - const lastNode = textNodesWithOffsets[textNodesWithOffsets.length - 1]; - return lastNode.offset + lastNode.node.length; } - - return 0; -}; -*/ - -export const getAbsoluteCaretPosition = (element: HTMLElement): number => { - const selection = window.getSelection(); - if (!selection?.focusNode) return 0; - - const collectTextNodesAndOffsets = ( - root: Node, - ): { - node: Text | Node; - offset: number; - length: number; - }[] => { - const nodes: { node: Text | Node; offset: number; length: number }[] = []; - let currentOffset = 0; - - // 모든 노드를 순회하면서 텍스트 노드와 그 부모 노드의 정보 수집 - const walker = document.createTreeWalker( - root, - NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, - null, - ); - - let node = walker.nextNode(); - while (node) { - if (node.nodeType === Node.TEXT_NODE && node.nodeValue !== "\n") { - nodes.push({ - node, - offset: currentOffset, - length: node.nodeValue?.length || 0, - }); - currentOffset += node.nodeValue?.length || 0; - } else if (node.nodeType === Node.ELEMENT_NODE) { - // 요소 노드도 추적 - nodes.push({ - node, - offset: currentOffset, - length: node.textContent?.length || 0, - }); - } - node = walker.nextNode(); - } - - return nodes; - }; - - const textNodesWithOffsets = collectTextNodesAndOffsets(element); - // focusNode가 텍스트 노드인 경우 - if (selection.focusNode.nodeType === Node.TEXT_NODE) { - const focusTextNode = selection.focusNode; - const nodeInfo = textNodesWithOffsets.find((info) => info.node === focusTextNode); - if (nodeInfo) { - return nodeInfo.offset + selection.focusOffset; - } - } - - // focusNode가 요소 노드인 경우 (스타일 태그 등) - if (selection.focusNode.nodeType === Node.ELEMENT_NODE) { - const focusElement = selection.focusNode; - - // 현재 요소의 모든 이전 텍스트 길이 계산 - let totalOffset = 0; - for (const nodeInfo of textNodesWithOffsets) { - if (nodeInfo.node === focusElement || focusElement.contains(nodeInfo.node)) { - if (nodeInfo.node === focusElement) { - return totalOffset + selection.focusOffset; - } - // focusOffset이 요소 내부의 특정 위치를 가리키는 경우 - if ( - selection.focusOffset > 0 && - nodeInfo.offset + nodeInfo.length >= selection.focusOffset - ) { - return nodeInfo.offset + selection.focusOffset; - } - } - totalOffset += nodeInfo.length; - } - return totalOffset; - } - - // focusNode가 element 자체인 경우 (빈 블록이나 모든 텍스트가 삭제된 경우) - if (selection.focusNode === element) { - // 선택된 오프셋까지의 모든 텍스트 길이 합산 - let totalOffset = 0; - for (const nodeInfo of textNodesWithOffsets) { - if (nodeInfo.offset < selection.focusOffset) { - totalOffset += nodeInfo.length; - } - } - return totalOffset; - } - - return 0; }; From 25ced5b68b549e1878905b985b269c1d8f3000bb Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 12:43:03 +0900 Subject: [PATCH 23/47] =?UTF-8?q?fix:=20span=20=EA=B5=AC=EC=A1=B0=EC=97=90?= =?UTF-8?q?=20=EB=A7=9E=EA=B2=8C=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/editor/utils/domSyncUtils.ts | 81 ++++++++++++------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/client/src/features/editor/utils/domSyncUtils.ts b/client/src/features/editor/utils/domSyncUtils.ts index e2cbc2d1..77465b25 100644 --- a/client/src/features/editor/utils/domSyncUtils.ts +++ b/client/src/features/editor/utils/domSyncUtils.ts @@ -1,18 +1,39 @@ import { Block } from "@noctaCrdt/Node"; +import { css } from "styled-system/css"; + +export const TEXT_STYLES: Record = { + bold: "bold", + italic: "italic", + underline: "underline", + strikethrough: "strikethrough", +}; interface SetInnerHTMLProps { element: HTMLDivElement; block: Block; } -const getHtmlTag = (style: string): string => { - const tagMappings: Record = { - bold: "b", // bold는 - italic: "i", // italic은 - underline: "u", // underline은 - strikethrough: "s", // strikethrough는 - }; - return tagMappings[style] || "span"; // 기본은 +const getClassNames = (styles: Set): string => { + // underline과 strikethrough가 함께 있는 경우 특별 처리 + if (styles.has("underline") && styles.has("strikethrough")) { + return css({ + textDecoration: "underline line-through", + fontWeight: styles.has("bold") ? "bold" : "normal", + fontStyle: styles.has("italic") ? "italic" : "normal", + }); + } + + // 일반적인 경우 + return css({ + textDecoration: styles.has("underline") + ? "underline" + : styles.has("strikethrough") + ? "line-through" + : "none", + // textStyle 속성 대신 직접 스타일 지정 + fontWeight: styles.has("bold") ? "bold" : "normal", + fontStyle: styles.has("italic") ? "italic" : "normal", + }); }; export const setInnerHTML = ({ element, block }: SetInnerHTMLProps): void => { @@ -29,7 +50,7 @@ export const setInnerHTML = ({ element, block }: SetInnerHTMLProps): void => { // 해당 위치에 적용되어야 하는 모든 스타일 수집 chars.forEach((c, i) => { if (i === index) { - c.style.forEach((style) => styleSet.add(style)); + c.style.forEach((style) => styleSet.add(TEXT_STYLES[style])); } }); @@ -38,39 +59,37 @@ export const setInnerHTML = ({ element, block }: SetInnerHTMLProps): void => { let html = ""; let currentStyles = new Set(); + let spanOpen = false; chars.forEach((char, index) => { const targetStyles = positionStyles[index]; - // 제거해야 할 스타일 (현재는 있지만 다음에는 필요 없는 스타일) - const stylesToRemove = [...currentStyles].filter((style) => !targetStyles.has(style)); + // 스타일이 변경되었는지 확인 + const styleChanged = !setsEqual(currentStyles, targetStyles); - // 추가해야 할 스타일 (현재는 없지만 다음에는 필요한 스타일) - const stylesToAdd = [...targetStyles].filter((style) => !currentStyles.has(style)); - - // 제거할 스타일 태그 닫기 (역순으로) - stylesToRemove.reverse().forEach((style) => { - html += ``; - }); + // 스타일이 변경되었으면 현재 span 태그 닫기 + if (styleChanged && spanOpen) { + html += ""; + spanOpen = false; + } - // 새로운 스타일 태그 열기 - stylesToAdd.forEach((style) => { - html += `<${getHtmlTag(style)}>`; - }); + // 새로운 스타일 조합으로 span 태그 열기 + if (styleChanged && targetStyles.size > 0) { + const className = getClassNames(targetStyles); + html += ``; + spanOpen = true; + } // 텍스트 추가 html += sanitizeText(char.value); // 다음 문자로 넘어가기 전에 현재 스타일 상태 업데이트 - currentStyles = new Set(targetStyles); - - // 마지막 문자이거나 다음 문자와 스타일이 다른 경우 - if (index === chars.length - 1 || !setsEqual(targetStyles, positionStyles[index + 1])) { - // 현재 열려있는 모든 태그 닫기 - [...currentStyles].reverse().forEach((style) => { - html += ``; - }); - currentStyles.clear(); + currentStyles = targetStyles; + + // 마지막 문자이고 span이 열려있으면 닫기 + if (index === chars.length - 1 && spanOpen) { + html += ""; + spanOpen = false; } }); From 6008698da620e45637e94e019200aedc66a51ab0 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 12:43:22 +0900 Subject: [PATCH 24/47] =?UTF-8?q?fix:=20=EC=BA=90=EB=9F=BF=20=EC=9D=B4?= =?UTF-8?q?=EC=83=81=ED=95=98=EA=B2=8C=20=EC=9D=B4=EB=8F=99=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/editor/Editor.tsx | 32 ++++++++++++++++++--------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index 4bc94ac2..ad238147 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -120,30 +120,42 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr const newContent = element.textContent || ""; const currentContent = block.crdt.read(); const caretPosition = getAbsoluteCaretPosition(element); - console.log(caretPosition, "블록의 캐럿 위치"); + console.log({ + newContent, + currentContent, + caretPosition, + }); if (newContent.length > currentContent.length) { let charNode: RemoteCharInsertOperation; + // 캐럿 위치 유효성 검사 + const validCaretPosition = Math.min(Math.max(0, caretPosition), currentContent.length); + // 맨 앞에 삽입 if (caretPosition === 0) { const [addedChar] = newContent; charNode = block.crdt.localInsert(0, addedChar, block.id, pageId); - editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; } else if (caretPosition > currentContent.length) { + // 맨 뒤에 삽입 const addedChar = newContent[newContent.length - 1]; charNode = block.crdt.localInsert(currentContent.length, addedChar, block.id, pageId); - editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; } else { - const addedChar = newContent[caretPosition - 1]; - charNode = block.crdt.localInsert(caretPosition - 1, addedChar, block.id, pageId); - editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; + // 중간에 삽입 + const addedChar = newContent[validCaretPosition - 1]; + charNode = block.crdt.localInsert(validCaretPosition - 1, addedChar, block.id, pageId); } + editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; sendCharInsertOperation({ node: charNode.node, blockId: block.id, pageId }); } else if (newContent.length < currentContent.length) { // 문자가 삭제된 경우 - operationNode = block.crdt.localDelete(caretPosition, block.id, pageId); - sendCharDeleteOperation(operationNode); - // editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; - editorCRDT.current.currentBlock!.crdt.currentCaret -= 1; + // 삭제 위치 계산 + const deletePosition = Math.max(0, caretPosition); + if (deletePosition >= 0 && deletePosition < currentContent.length) { + operationNode = block.crdt.localDelete(deletePosition, block.id, pageId); + sendCharDeleteOperation(operationNode); + + // 캐럿 위치 업데이트 + editorCRDT.current.currentBlock!.crdt.currentCaret = deletePosition; + } } setEditorState({ clock: editorCRDT.current.clock, From 776ed88dcb942aae9935000c3e773408eda0fef6 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 13:45:13 +0900 Subject: [PATCH 25/47] =?UTF-8?q?fix:=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=EB=90=9C=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/components/block/Block.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index 48dd112b..cbfdc498 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -59,6 +59,7 @@ export const Block: React.FC = memo( }); const handleInput = (e: React.FormEvent) => { + const currentElement = e.currentTarget; // 텍스트를 삭제하면
태그가 생김 // 이를 방지하기 위해
태그 찾아서 모두 삭제 const brElements = e.currentTarget.getElementsByTagName("br"); @@ -66,6 +67,26 @@ export const Block: React.FC = memo( e.preventDefault(); Array.from(brElements).forEach((br) => br.remove()); } + + // 빈 span 태그 제거 + const cleanEmptySpans = (element: HTMLElement) => { + const spans = element.getElementsByTagName("span"); + Array.from(spans).forEach((span) => { + // 텍스트 컨텐츠가 없거나 공백만 있는 경우 + // 텍스트 컨텐츠가 없거나 공백만 있는 경우 + if (!span.textContent || span.textContent.trim() === "") { + if (span.parentNode) { + console.log("빈 span 태그 제거"); + span.parentNode.removeChild(span); + } + } + }); + }; + + cleanEmptySpans(currentElement); + + const caretPosition = getAbsoluteCaretPosition(e.currentTarget); + block.crdt.currentCaret = caretPosition; onInput(e, block); }; @@ -127,6 +148,7 @@ export const Block: React.FC = memo( setSelectedNodes(nodes); openModal(); } + block.crdt.currentCaret = endOffset; }; const handleStyleSelect = (styleType: TextStyleType) => { @@ -144,6 +166,7 @@ export const Block: React.FC = memo( useEffect(() => { if (blockRef.current) { + console.log("setInnerHTML"); setInnerHTML({ element: blockRef.current, block }); } }, [block.crdt.serialize()]); From 2df4eb621760b22016e6415f3d00280130e10869 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 14:21:45 +0900 Subject: [PATCH 26/47] =?UTF-8?q?fix:=20=EB=B3=80=EA=B2=BD=EB=90=9C=20?= =?UTF-8?q?=EB=A6=AC=EC=B9=98=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스타일 적용된 텍스트 분할하거나 합병해도 텍스트 스타일 유지 - 리치 텍스트 구조에 맞게 화살표 핸들러 수정 --- .../editor/hooks/useMarkdownGrammer.ts | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/client/src/features/editor/hooks/useMarkdownGrammer.ts b/client/src/features/editor/hooks/useMarkdownGrammer.ts index d36ff3d5..4930dc22 100644 --- a/client/src/features/editor/hooks/useMarkdownGrammer.ts +++ b/client/src/features/editor/hooks/useMarkdownGrammer.ts @@ -71,6 +71,7 @@ export const useMarkdownGrammer = ({ e.preventDefault(); const caretPosition = getAbsoluteCaretPosition(e.currentTarget); const currentContent = currentBlock.crdt.read(); + const currentCharNodes = currentBlock.crdt.spread(); if (!currentContent && currentBlock.type !== "p") { currentBlock.type = "p"; @@ -96,6 +97,7 @@ export const useMarkdownGrammer = ({ // 현재 캐럿 위치를 기준으로 내용 분할 const afterContent = currentContent.slice(caretPosition); + const afterCharNode = currentCharNodes.slice(caretPosition); // 새 블록 생성 const operation = createNewBlock(currentIndex + 1); @@ -106,8 +108,15 @@ export const useMarkdownGrammer = ({ // 캐럿 이후의 텍스트 있으면 새 블록에 추가 if (afterContent) { afterContent.split("").forEach((char, i) => { + const currentCharNode = afterCharNode[i]; sendCharInsertOperation( - operation.node.crdt.localInsert(i, char, operation.node.id, pageId), + operation.node.crdt.localInsert( + i, + char, + operation.node.id, + pageId, + currentCharNode.style, + ), ); }); for (let i = currentContent.length - 1; i >= caretPosition; i--) { @@ -129,6 +138,7 @@ export const useMarkdownGrammer = ({ case "Backspace": { const currentContent = currentBlock.crdt.read(); + const currentCharNodes = currentBlock.crdt.spread(); if (currentContent === "") { e.preventDefault(); if (currentBlock.indent > 0) { @@ -179,15 +189,19 @@ export const useMarkdownGrammer = ({ currentIndex > 0 ? editorCRDT.LinkedList.findByIndex(currentIndex - 1) : null; if (prevBlock) { const prevBlockEndCaret = prevBlock.crdt.spread().length; - currentContent.split("").forEach((char) => { + for (let i = 0; i < currentContent.length; i++) { + const currentCharNode = currentCharNodes[i]; sendCharInsertOperation( prevBlock.crdt.localInsert( - prevBlock.crdt.read().length, - char, + prevBlockEndCaret + i, + currentContent[i], prevBlock.id, pageId, + currentCharNode.style, ), ); + } + currentContent.split("").forEach(() => { sendCharDeleteOperation( currentBlock.crdt.localDelete(0, currentBlock.id, pageId), ); @@ -264,8 +278,9 @@ export const useMarkdownGrammer = ({ return; } - const selection = window.getSelection(); - const caretPosition = selection?.focusOffset || 0; + // const selection = window.getSelection(); + // const caretPosition = selection?.focusOffset || 0; + const caretPosition = getAbsoluteCaretPosition(e.currentTarget); // 이동할 블록 결정 const targetIndex = e.key === "ArrowUp" ? currentIndex - 1 : currentIndex + 1; @@ -283,8 +298,9 @@ export const useMarkdownGrammer = ({ } case "ArrowLeft": case "ArrowRight": { - const selection = window.getSelection(); - const caretPosition = selection?.focusOffset || 0; + // const selection = window.getSelection(); + // const caretPosition = selection?.focusOffset || 0; + const caretPosition = getAbsoluteCaretPosition(e.currentTarget); const textLength = currentBlock.crdt.read().length; // 왼쪽 끝에서 이전 블록으로 From 5fceaa0696469e257dcfb53af6e75b59e1c33594 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 14:36:09 +0900 Subject: [PATCH 27/47] =?UTF-8?q?feat:=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EC=9E=88=EB=8A=94=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=ED=95=98=EA=B1=B0=EB=82=98=20=EB=B6=84=ED=95=A0?= =?UTF-8?q?=ED=95=A0=20=EB=95=8C,=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80=EB=90=98=EA=B2=8C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=A0=84=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- @noctaCrdt/Crdt.ts | 7 +++++++ @noctaCrdt/Interfaces.ts | 1 + server/src/crdt/crdt.gateway.ts | 2 ++ 3 files changed, 10 insertions(+) diff --git a/@noctaCrdt/Crdt.ts b/@noctaCrdt/Crdt.ts index c30a0d66..76387770 100644 --- a/@noctaCrdt/Crdt.ts +++ b/@noctaCrdt/Crdt.ts @@ -230,6 +230,7 @@ export class BlockCRDT extends CRDT { node, blockId, pageId, + style: node.style || [], }; return operation; @@ -271,6 +272,12 @@ export class BlockCRDT extends CRDT { newNode.next = operation.node.next; newNode.prev = operation.node.prev; + if (operation.style && operation.style.length > 0) { + operation.style.forEach((style) => { + newNode.style.push(style); + }); + } + this.LinkedList.insertById(newNode); if (this.clock <= newNode.id.clock) { diff --git a/@noctaCrdt/Interfaces.ts b/@noctaCrdt/Interfaces.ts index d9cd3ba8..d82306da 100644 --- a/@noctaCrdt/Interfaces.ts +++ b/@noctaCrdt/Interfaces.ts @@ -38,6 +38,7 @@ export interface RemoteCharInsertOperation { node: Char; blockId: BlockId; pageId: string; + style?: string[]; } export interface RemoteBlockDeleteOperation { diff --git a/server/src/crdt/crdt.gateway.ts b/server/src/crdt/crdt.gateway.ts index 7a7ff352..48d7c229 100644 --- a/server/src/crdt/crdt.gateway.ts +++ b/server/src/crdt/crdt.gateway.ts @@ -275,6 +275,8 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa const operation = { node: data.node, blockId: data.blockId, + pageId: data.pageId, + style: data.style || [], }; client.broadcast.emit("insert/char", operation); } catch (error) { From a6305621d551f5fb3a389ac287633f487dcdb1a4 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 14:40:48 +0900 Subject: [PATCH 28/47] =?UTF-8?q?chore:=20lint=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- @noctaCrdt/Node.ts | 2 +- client/src/features/editor/Editor.tsx | 2 +- client/src/features/editor/components/block/Block.tsx | 2 +- client/src/features/editor/hooks/useTextOptions.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/@noctaCrdt/Node.ts b/@noctaCrdt/Node.ts index 77eb0ede..a8c9d67f 100644 --- a/@noctaCrdt/Node.ts +++ b/@noctaCrdt/Node.ts @@ -1,6 +1,6 @@ // Node.ts import { NodeId, BlockId, CharId } from "./NodeId"; -import { AnimationType, ElementType, TextStyleType } from "./Interfaces"; +import { AnimationType, ElementType } from "./Interfaces"; import { BlockCRDT } from "./Crdt"; export abstract class Node { diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index ad238147..d829c3ce 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -8,7 +8,7 @@ import { RemoteCharInsertOperation, serializedEditorDataProps, } from "node_modules/@noctaCrdt/Interfaces.ts"; -import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from "react"; +import { useRef, useState, useCallback, useEffect, useMemo } from "react"; import { useSocketStore } from "@src/stores/useSocketStore.ts"; import { setCaretPosition, getAbsoluteCaretPosition } from "@src/utils/caretUtils.ts"; import { diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index cbfdc498..cf39ef3e 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -6,7 +6,7 @@ import { BlockId } from "@noctaCrdt/NodeId"; import { motion } from "framer-motion"; import { memo, useEffect, useRef, useState } from "react"; import { useModal } from "@src/components/modal/useModal"; -import { setCaretPosition, getAbsoluteCaretPosition } from "@src/utils/caretUtils"; +import { getAbsoluteCaretPosition } from "@src/utils/caretUtils"; import { useBlockAnimation } from "../../hooks/useBlockAnimtaion"; import { setInnerHTML } from "../../utils/domSyncUtils"; import { IconBlock } from "../IconBlock/IconBlock"; diff --git a/client/src/features/editor/hooks/useTextOptions.ts b/client/src/features/editor/hooks/useTextOptions.ts index 1c2fa9e6..4fd16aff 100644 --- a/client/src/features/editor/hooks/useTextOptions.ts +++ b/client/src/features/editor/hooks/useTextOptions.ts @@ -1,5 +1,5 @@ import { EditorCRDT } from "@noctaCrdt/Crdt"; -import { RemoteBlockUpdateOperation, TextStyleType } from "@noctaCrdt/Interfaces"; +import { TextStyleType } from "@noctaCrdt/Interfaces"; import { Block, Char } from "@noctaCrdt/Node"; import { BlockId } from "@noctaCrdt/NodeId"; import { useCallback } from "react"; From 00f1bc5c9bc33db60e136a1a7aee832f5bcb2995 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 16:39:44 +0900 Subject: [PATCH 29/47] =?UTF-8?q?feat:=20=EC=84=A0=ED=83=9D=EB=90=9C=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=EC=9D=98=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=EC=97=90=20=EB=94=B0=EB=9D=BC=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TextOptionModal/TextOptionModal.tsx | 154 +++++++++++++++--- .../editor/components/block/Block.tsx | 1 + 2 files changed, 134 insertions(+), 21 deletions(-) diff --git a/client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx b/client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx index 30026afe..3312dda4 100644 --- a/client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx +++ b/client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx @@ -1,9 +1,11 @@ +import { Char } from "@noctaCrdt/Node"; import { motion } from "framer-motion"; import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { modalContainer, optionButton, optionModal } from "./TextOptionModal.style"; interface SelectionModalProps { + selectedNodes: Array | null; isOpen: boolean; onBoldSelect: () => void; onItalicSelect: () => void; @@ -17,6 +19,7 @@ const ModalPortal = ({ children }: { children: React.ReactNode }) => { }; export const TextOptionModal = ({ + selectedNodes, isOpen, onBoldSelect, onItalicSelect, @@ -29,6 +32,12 @@ export const TextOptionModal = ({ top: 0, left: 0, }); + const [styleState, setStyleState] = useState({ + isBold: false, + isItalic: false, + isUnderline: false, + isStrike: false, + }); useEffect(() => { if (!isOpen) return; @@ -60,38 +69,141 @@ export const TextOptionModal = ({ return () => { document.removeEventListener("mousedown", handleClickOutside); + setStyleState({ + isBold: false, + isItalic: false, + isUnderline: false, + isStrike: false, + }); }; }, [isOpen, onClose]); + useEffect(() => { + if (!selectedNodes || selectedNodes.length === 0) { + setStyleState({ + isBold: false, + isItalic: false, + isUnderline: false, + isStrike: false, + }); + return; + } + + // 각 스타일의 출현 횟수를 계산 + const styleCounts = { + bold: 0, + italic: 0, + underline: 0, + strike: 0, + }; + + // 각 노드의 스타일을 확인하고 카운트 + selectedNodes.forEach((node) => { + // node.style이 undefined인 경우 빈 배열로 처리 + const nodeStyles = Array.isArray(node.style) ? node.style : node.style ? [node.style] : []; + + if (nodeStyles.includes("bold")) styleCounts.bold += 1; + if (nodeStyles.includes("italic")) styleCounts.italic += 1; + if (nodeStyles.includes("underline")) styleCounts.underline += 1; + if (nodeStyles.includes("strike")) styleCounts.strike += 1; + }); + + const totalNodes = selectedNodes.length; + + // 모든 노드가 해당 스타일을 가지고 있는 경우에만 true + setStyleState({ + isBold: styleCounts.bold === totalNodes, + isItalic: styleCounts.italic === totalNodes, + isUnderline: styleCounts.underline === totalNodes, + isStrike: styleCounts.strike === totalNodes, + }); + }, [selectedNodes]); + if (!isOpen) return; return ( - {isOpen && ( - -
- - + - + - + -
-
- )} +
+ + + ); }; diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index c2419ede..78cc5c07 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -217,6 +217,7 @@ export const Block: React.FC = memo( /> handleStyleSelect("bold")} From fe6c112bb8a6516ee6c3b459fba0092412df167c Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 19:56:26 +0900 Subject: [PATCH 30/47] =?UTF-8?q?fix:=20=EC=84=9C=EC=9C=A4=EB=8B=98=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/editor/components/block/Block.style.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/features/editor/components/block/Block.style.ts b/client/src/features/editor/components/block/Block.style.ts index e1ced415..f283cf60 100644 --- a/client/src/features/editor/components/block/Block.style.ts +++ b/client/src/features/editor/components/block/Block.style.ts @@ -39,6 +39,7 @@ export const contentWrapperStyle = cva({ flex: 1, flexDirection: "row", alignItems: "center", + width: "100%", }, }); @@ -54,7 +55,7 @@ const baseTextStyle = { padding: "spacing.sm", color: "gray.900", backgroundColor: "transparent", - display: "flex", + display: "inline", alignItems: "center", }; From a21ba107a63ee02deb3517000380d506ec64b184 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 20:10:01 +0900 Subject: [PATCH 31/47] =?UTF-8?q?feat:=20=EC=83=89=EC=83=81=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=86=8D=EC=84=B1=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 --- @noctaCrdt/Crdt.ts | 20 ++++++++++++++++++-- @noctaCrdt/Interfaces.ts | 13 +++++++++++++ @noctaCrdt/LinkedList.ts | 2 +- @noctaCrdt/Node.ts | 14 ++++++++++++-- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/@noctaCrdt/Crdt.ts b/@noctaCrdt/Crdt.ts index 76387770..ab39b149 100644 --- a/@noctaCrdt/Crdt.ts +++ b/@noctaCrdt/Crdt.ts @@ -261,7 +261,15 @@ export class BlockCRDT extends CRDT { localUpdate(node: Char, blockId: BlockId, pageId: string): RemoteCharUpdateOperation { const updatedChar = this.LinkedList.nodeMap[JSON.stringify(node.id)]; - updatedChar.style = [...node.style]; + if (node.style && node.style.length > 0) { + updatedChar.style = [...node.style]; + } + if (node.color) { + updatedChar.color = node.color; + } + if (node.backgroundColor !== updatedChar.backgroundColor) { + updatedChar.backgroundColor = node.backgroundColor; + } return { node: updatedChar, blockId, pageId }; } @@ -298,7 +306,15 @@ export class BlockCRDT extends CRDT { remoteUpdate(operation: RemoteCharUpdateOperation): void { const updatedChar = this.LinkedList.nodeMap[JSON.stringify(operation.node.id)]; - updatedChar.style = [...operation.node.style]; + if (operation.node.style && operation.node.style.length > 0) { + updatedChar.style = [...operation.node.style]; + } + if (operation.node.color) { + updatedChar.color = operation.node.color; + } + if (operation.node.backgroundColor) { + updatedChar.backgroundColor = operation.node.backgroundColor; + } } serialize(): CRDTSerializedProps { diff --git a/@noctaCrdt/Interfaces.ts b/@noctaCrdt/Interfaces.ts index d82306da..1a4884a0 100644 --- a/@noctaCrdt/Interfaces.ts +++ b/@noctaCrdt/Interfaces.ts @@ -9,6 +9,19 @@ export type AnimationType = "none" | "highlight" | "gradation"; export type TextStyleType = "bold" | "italic" | "underline" | "strikethrough"; +export type BackgroundColorType = + | "black" + | "red" + | "green" + | "blue" + | "white" + | "yellow" + | "purple" + | "brown" + | "transparent"; + +export type TextColorType = Exclude; + export interface InsertOperation { node: Block | Char; } diff --git a/@noctaCrdt/LinkedList.ts b/@noctaCrdt/LinkedList.ts index 9e15a07b..f521afe8 100644 --- a/@noctaCrdt/LinkedList.ts +++ b/@noctaCrdt/LinkedList.ts @@ -144,7 +144,7 @@ export abstract class LinkedList> { return node; } - insertAtIndex(index: number, value: string, id: T["id"]): InsertOperation { + insertAtIndex(index: number, value: string, id: T["id"]) { try { const node = this.createNode(value, id); this.setNode(id, node); diff --git a/@noctaCrdt/Node.ts b/@noctaCrdt/Node.ts index a8c9d67f..f0bb2148 100644 --- a/@noctaCrdt/Node.ts +++ b/@noctaCrdt/Node.ts @@ -1,6 +1,6 @@ // Node.ts import { NodeId, BlockId, CharId } from "./NodeId"; -import { AnimationType, ElementType } from "./Interfaces"; +import { AnimationType, ElementType, TextColorType, BackgroundColorType } from "./Interfaces"; import { BlockCRDT } from "./Crdt"; export abstract class Node { @@ -90,14 +90,22 @@ export class Block extends Node { export class Char extends Node { style: string[]; + color: TextColorType; + backgroundColor: BackgroundColorType; constructor(value: string, id: CharId) { super(value, id); this.style = []; + this.color = "black"; + this.backgroundColor = "transparent"; } serialize(): any { - return super.serialize(); + return { + ...super.serialize(), + color: this.color, + backgroundColor: this.backgroundColor, + }; } static deserialize(data: any): Char { @@ -106,6 +114,8 @@ export class Char extends Node { char.next = data.next ? CharId.deserialize(data.next) : null; char.prev = data.prev ? CharId.deserialize(data.prev) : null; char.style = data.style ? data.style : []; + char.color = data.color ? data.color : "black"; + char.backgroundColor = data.backgroundColor ? data.backgroundColor : "transparent"; return char; } } From 1eeb4c3b4c4085e49a99a7f3180e9d0cc1737314 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 20:10:17 +0900 Subject: [PATCH 32/47] =?UTF-8?q?design:=20=EC=83=89=EC=83=81=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=9D=84=20=EC=9C=84=ED=95=9C=20=ED=86=A0=ED=81=B0=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 --- client/src/constants/color.ts | 3 +++ client/src/styles/tokens/color.ts | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/client/src/constants/color.ts b/client/src/constants/color.ts index 35dd471b..3c3e2871 100644 --- a/client/src/constants/color.ts +++ b/client/src/constants/color.ts @@ -9,4 +9,7 @@ export const COLOR = { RED: "#F24150", YELLOW: "#FEA642", GREEN: "#1BBF44", + PURPLE: "#A142FE", + BROWN: "#8B4513", + BLUE: "#4285F4", }; diff --git a/client/src/styles/tokens/color.ts b/client/src/styles/tokens/color.ts index 2dbf4edd..19aabae6 100644 --- a/client/src/styles/tokens/color.ts +++ b/client/src/styles/tokens/color.ts @@ -23,4 +23,13 @@ export const colors = { green: { value: COLOR.GREEN, }, + purple: { + value: COLOR.PURPLE, + }, + brown: { + value: COLOR.BROWN, + }, + blue: { + value: COLOR.BLUE, + }, }; From 56006b99a13499afa9d19acacb80c919f2dde90f Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 20:11:26 +0900 Subject: [PATCH 33/47] =?UTF-8?q?feat:=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=83=89=EC=83=81,=20=EB=B0=B0=EA=B2=BD=20update=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/editor/hooks/useTextOptions.ts | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/client/src/features/editor/hooks/useTextOptions.ts b/client/src/features/editor/hooks/useTextOptions.ts index 4fd16aff..86c01153 100644 --- a/client/src/features/editor/hooks/useTextOptions.ts +++ b/client/src/features/editor/hooks/useTextOptions.ts @@ -1,5 +1,5 @@ import { EditorCRDT } from "@noctaCrdt/Crdt"; -import { TextStyleType } from "@noctaCrdt/Interfaces"; +import { TextStyleType, TextColorType, BackgroundColorType } from "@noctaCrdt/Interfaces"; import { Block, Char } from "@noctaCrdt/Node"; import { BlockId } from "@noctaCrdt/NodeId"; import { useCallback } from "react"; @@ -74,7 +74,71 @@ export const useTextOptionSelect = ({ [pageId, sendCharUpdateOperation, editorCRDT], ); + // 텍스트 색상 업데이트 함수 + const handleTextColorUpdate = useCallback( + (color: TextColorType, blockId: BlockId, nodes: Array | null) => { + if (!nodes || nodes.length === 0) return; + const block = editorCRDT.LinkedList.getNode(blockId) as Block; + if (!block) return; + + nodes.forEach((node) => { + const char = block.crdt.LinkedList.getNode(node.id) as Char; + if (!char) return; + + // 색상 업데이트 + char.color = color; + + // 업데이트 및 전송 + block.crdt.localUpdate(char, node.id, pageId); + sendCharUpdateOperation({ + node: char, + blockId, + pageId, + }); + }); + + setEditorState({ + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, + }); + }, + [pageId, sendCharUpdateOperation, editorCRDT], + ); + + // 배경색상 업데이트 함수 + const handleBackgroundColorUpdate = useCallback( + (color: BackgroundColorType, blockId: BlockId, nodes: Array | null) => { + if (!nodes || nodes.length === 0) return; + const block = editorCRDT.LinkedList.getNode(blockId) as Block; + if (!block) return; + + nodes.forEach((node) => { + const char = block.crdt.LinkedList.getNode(node.id) as Char; + if (!char) return; + + // 배경색상 업데이트 + char.backgroundColor = color; + + // 업데이트 및 전송 + block.crdt.localUpdate(char, node.id, pageId); + sendCharUpdateOperation({ + node: char, + blockId, + pageId, + }); + }); + + setEditorState({ + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, + }); + }, + [pageId, sendCharUpdateOperation, editorCRDT], + ); + return { onTextStyleUpdate: handleStyleUpdate, + onTextColorUpdate: handleTextColorUpdate, + onTextBackgroundColorUpdate: handleBackgroundColorUpdate, }; }; From 52127333c3dd0be19b9a54ac447a71b7b38f427c Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 20:11:49 +0900 Subject: [PATCH 34/47] =?UTF-8?q?feat:=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=83=89=EC=83=81,=20=EB=B0=B0=EA=B2=BD=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EB=B0=98=EC=98=81=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/editor/Editor.tsx | 14 +- .../editor/components/block/Block.tsx | 42 +++++- .../src/features/editor/utils/domSyncUtils.ts | 120 ++++++++++++++++++ 3 files changed, 170 insertions(+), 6 deletions(-) diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index 8c8df28d..2f9ef559 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -85,11 +85,13 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr sendCharInsertOperation, }); - const { onTextStyleUpdate } = useTextOptionSelect({ - editorCRDT: editorCRDT.current, - setEditorState, - pageId, - }); + const { onTextStyleUpdate, onTextColorUpdate, onTextBackgroundColorUpdate } = useTextOptionSelect( + { + editorCRDT: editorCRDT.current, + setEditorState, + pageId, + }, + ); const handleTitleChange = (e: React.ChangeEvent) => { onTitleChange(e.target.value); @@ -339,6 +341,8 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onCopySelect={handleCopySelect} onDeleteSelect={handleDeleteSelect} onTextStyleUpdate={onTextStyleUpdate} + onTextColorUpdate={onTextColorUpdate} + onTextBackgroundColorUpdate={onTextBackgroundColorUpdate} /> ))} diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index 78cc5c07..a4fc6d07 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -1,6 +1,12 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { AnimationType, ElementType, TextStyleType } from "@noctaCrdt/Interfaces"; +import { + AnimationType, + ElementType, + TextColorType, + TextStyleType, + BackgroundColorType, +} from "@noctaCrdt/Interfaces"; import { Block as CRDTBlock, Char } from "@noctaCrdt/Node"; import { BlockId } from "@noctaCrdt/NodeId"; import { motion } from "framer-motion"; @@ -32,6 +38,12 @@ interface BlockProps { blockId: BlockId, nodes: Array | null, ) => void; + onTextColorUpdate: (color: TextColorType, blockId: BlockId, nodes: Array) => void; + onTextBackgroundColorUpdate: ( + color: BackgroundColorType, + blockId: BlockId, + nodes: Array, + ) => void; } export const Block: React.FC = memo( ({ @@ -47,6 +59,8 @@ export const Block: React.FC = memo( onCopySelect, onDeleteSelect, onTextStyleUpdate, + onTextColorUpdate, + onTextBackgroundColorUpdate, }: BlockProps) => { const blockRef = useRef(null); const { isOpen, openModal, closeModal } = useModal(); @@ -166,6 +180,30 @@ export const Block: React.FC = memo( } }; + const handleTextColorSelect = (color: TextColorType) => { + if (blockRef.current && selectedNodes) { + const selection = window.getSelection(); + onTextColorUpdate(color, block.id, selectedNodes); + + const position = selection?.focusOffset || 0; + block.crdt.currentCaret = position; + + closeModal(); + } + }; + + const handleTextBackgroundColorSelect = (color: BackgroundColorType) => { + if (blockRef.current && selectedNodes) { + const selection = window.getSelection(); + onTextBackgroundColorUpdate(color, block.id, selectedNodes); + + const position = selection?.focusOffset || 0; + block.crdt.currentCaret = position; + + closeModal(); + } + }; + useEffect(() => { if (blockRef.current) { console.log("setInnerHTML"); @@ -224,6 +262,8 @@ export const Block: React.FC = memo( onItalicSelect={() => handleStyleSelect("italic")} onUnderlineSelect={() => handleStyleSelect("underline")} onStrikeSelect={() => handleStyleSelect("strikethrough")} + onTextColorSelect={handleTextColorSelect} + onTextBackgroundColorSelect={handleTextBackgroundColorSelect} /> ); diff --git a/client/src/features/editor/utils/domSyncUtils.ts b/client/src/features/editor/utils/domSyncUtils.ts index 77465b25..d9d031ac 100644 --- a/client/src/features/editor/utils/domSyncUtils.ts +++ b/client/src/features/editor/utils/domSyncUtils.ts @@ -1,4 +1,6 @@ +import { TextColorType, BackgroundColorType } from "@noctaCrdt/Interfaces"; import { Block } from "@noctaCrdt/Node"; +import { COLOR } from "@src/constants/color"; import { css } from "styled-system/css"; export const TEXT_STYLES: Record = { @@ -13,6 +15,121 @@ interface SetInnerHTMLProps { block: Block; } +interface TextStyleState { + styles: Set; + color: TextColorType; + backgroundColor: BackgroundColorType; +} + +const textColorMap: Record = { + black: COLOR.GRAY_900, + red: COLOR.RED, + green: COLOR.GREEN, + blue: COLOR.BLUE, + yellow: COLOR.YELLOW, + purple: COLOR.PURPLE, + brown: COLOR.BROWN, + white: COLOR.WHITE, +}; + +const getClassNames = (state: TextStyleState): string => { + // underline과 strikethrough가 함께 있는 경우 특별 처리 + const baseStyles = { + textDecoration: + state.styles.has("underline") && state.styles.has("strikethrough") + ? "underline line-through" + : state.styles.has("underline") + ? "underline" + : state.styles.has("strikethrough") + ? "line-through" + : "none", + fontWeight: state.styles.has("bold") ? "bold" : "normal", + fontStyle: state.styles.has("italic") ? "italic" : "normal", + color: textColorMap[state.color], + }; + + // backgroundColor가 transparent가 아닐 때만 추가 + if (state.backgroundColor !== "transparent") { + return css({ + ...baseStyles, + backgroundColor: textColorMap[state.backgroundColor], + }); + } + + return css(baseStyles); +}; + +export const setInnerHTML = ({ element, block }: SetInnerHTMLProps): void => { + const chars = block.crdt.LinkedList.spread(); + if (chars.length === 0) { + element.innerHTML = ""; + return; + } + + // 각 위치별 모든 적용된 스타일을 추적 + const positionStyles: TextStyleState[] = chars.map((char) => { + const styleSet = new Set(); + + // 현재 문자의 스타일 수집 + char.style.forEach((style) => styleSet.add(TEXT_STYLES[style])); + + return { + styles: styleSet, + color: char.color, + backgroundColor: char.backgroundColor, + }; + }); + + let html = ""; + let currentState: TextStyleState = { + styles: new Set(), + color: "black", + backgroundColor: "transparent", + }; + let spanOpen = false; + + chars.forEach((char, index) => { + const targetState = positionStyles[index]; + + // 스타일, 색상, 배경색 변경 확인 + const styleChanged = + !setsEqual(currentState.styles, targetState.styles) || + currentState.color !== targetState.color || + currentState.backgroundColor !== targetState.backgroundColor; + + // 변경되었으면 현재 span 태그 닫기 + if (styleChanged && spanOpen) { + html += ""; + spanOpen = false; + } + + // 새로운 스타일 조합으로 span 태그 열기 + if (styleChanged) { + const className = getClassNames(targetState); + html += ``; + spanOpen = true; + } + + // 텍스트 추가 + html += sanitizeText(char.value); + + // 다음 문자로 넘어가기 전에 현재 상태 업데이트 + currentState = targetState; + + // 마지막 문자이고 span이 열려있으면 닫기 + if (index === chars.length - 1 && spanOpen) { + html += ""; + spanOpen = false; + } + }); + + // DOM 업데이트 + if (element.innerHTML !== html) { + element.innerHTML = html; + } +}; + +/* const getClassNames = (styles: Set): string => { // underline과 strikethrough가 함께 있는 경우 특별 처리 if (styles.has("underline") && styles.has("strikethrough")) { @@ -35,7 +152,9 @@ const getClassNames = (styles: Set): string => { fontStyle: styles.has("italic") ? "italic" : "normal", }); }; +*/ +/* export const setInnerHTML = ({ element, block }: SetInnerHTMLProps): void => { const chars = block.crdt.LinkedList.spread(); if (chars.length === 0) { @@ -98,6 +217,7 @@ export const setInnerHTML = ({ element, block }: SetInnerHTMLProps): void => { element.innerHTML = html; } }; +*/ // Set 비교 헬퍼 함수 const setsEqual = (a: Set, b: Set): boolean => { From 63a2ac8bee929c9cfcfa2136e01cf1624869a61e Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 20:12:58 +0900 Subject: [PATCH 35/47] =?UTF-8?q?feat:=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=EC=B0=BD=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 텍스트 스타일: 기존 레이아웃과 동일 - 텍스트 색상: 버튼에 hover하면 서브 모달 생성. 서브 모달에서 색상 클릭하면 색상 적용 - 배경 색상: 버튼에 hover하면 서브 모달 생성. 서브 모달에서 색상 클릭하면 배경 색상 적욕 --- .../BackgroundColorOptionModal.style.ts | 63 ++++++++++ .../BackgroundColorOptionModal.tsx | 52 +++++++++ .../TextColorOptionModal.style.ts | 65 +++++++++++ .../ColorOptionModal/TextColorOptionModal.tsx | 48 ++++++++ .../TextOptionModal/TextOptionModal.style.ts | 85 +++++++++++++- .../TextOptionModal/TextOptionModal.tsx | 110 +++++++++++++++++- 6 files changed, 419 insertions(+), 4 deletions(-) create mode 100644 client/src/features/editor/components/ColorOptionModal/BackgroundColorOptionModal.style.ts create mode 100644 client/src/features/editor/components/ColorOptionModal/BackgroundColorOptionModal.tsx create mode 100644 client/src/features/editor/components/ColorOptionModal/TextColorOptionModal.style.ts create mode 100644 client/src/features/editor/components/ColorOptionModal/TextColorOptionModal.tsx diff --git a/client/src/features/editor/components/ColorOptionModal/BackgroundColorOptionModal.style.ts b/client/src/features/editor/components/ColorOptionModal/BackgroundColorOptionModal.style.ts new file mode 100644 index 00000000..f1a65f64 --- /dev/null +++ b/client/src/features/editor/components/ColorOptionModal/BackgroundColorOptionModal.style.ts @@ -0,0 +1,63 @@ +import { BackgroundColorType } from "@noctaCrdt/Interfaces"; +import { css, cva } from "@styled-system/css"; + +type ColorVariants = { + [K in BackgroundColorType]: { backgroundColor: string }; +}; + +export const colorPaletteModal = css({ + zIndex: 1001, + borderRadius: "4px", + minWidth: "120px", // 3x3 그리드를 위한 최소 너비 + padding: "4px", + backgroundColor: "white", + boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)", +}); + +export const colorPaletteContainer = css({ + display: "grid", + gap: "4px", + gridTemplateColumns: "repeat(3, 1fr)", + width: "100%", +}); + +export const colorOptionButton = css({ + display: "flex", + justifyContent: "center", + alignItems: "center", + border: "none", + borderRadius: "4px", + width: "28px", + height: "28px", + margin: "0 2px", + padding: "2px", + transition: "transform 0.2s", + cursor: "pointer", + "&:hover": { + transform: "scale(1.1)", + }, +}); + +const colorVariants: ColorVariants = { + transparent: { backgroundColor: "#C1D7F4" }, + black: { backgroundColor: "#2B4158" }, + red: { backgroundColor: "#F24150" }, + green: { backgroundColor: "#1BBF44" }, + blue: { backgroundColor: "#4285F4" }, + yellow: { backgroundColor: "#FEA642" }, + purple: { backgroundColor: "#A142FE" }, + brown: { backgroundColor: "#8B4513" }, + white: { backgroundColor: "#EEEEEE" }, +}; + +export const backgroundColorIndicator = cva({ + base: { + borderRadius: "3px", + width: "100%", + height: "100%", + transition: "all 0.2s", + }, + variants: { + color: colorVariants, + }, +}); diff --git a/client/src/features/editor/components/ColorOptionModal/BackgroundColorOptionModal.tsx b/client/src/features/editor/components/ColorOptionModal/BackgroundColorOptionModal.tsx new file mode 100644 index 00000000..4bed8311 --- /dev/null +++ b/client/src/features/editor/components/ColorOptionModal/BackgroundColorOptionModal.tsx @@ -0,0 +1,52 @@ +import { BackgroundColorType } from "@noctaCrdt/Interfaces"; +import { + backgroundColorIndicator, + colorOptionButton, + colorPaletteContainer, + colorPaletteModal, +} from "./BackgroundColorOptionModal.style.ts"; + +const COLORS: BackgroundColorType[] = [ + "black", + "red", + "blue", + "green", + "yellow", + "purple", + "brown", + "white", + "transparent", +]; + +interface BackgroundColorOptionModalProps { + onColorSelect: (color: BackgroundColorType) => void; + position: { top: number; left: number }; +} + +export const BackgroundColorOptionModal = ({ + onColorSelect, + position, +}: BackgroundColorOptionModalProps) => { + return ( +
+
+ {COLORS.map((color) => ( + + ))} +
+
+ ); +}; diff --git a/client/src/features/editor/components/ColorOptionModal/TextColorOptionModal.style.ts b/client/src/features/editor/components/ColorOptionModal/TextColorOptionModal.style.ts new file mode 100644 index 00000000..090bcdca --- /dev/null +++ b/client/src/features/editor/components/ColorOptionModal/TextColorOptionModal.style.ts @@ -0,0 +1,65 @@ +import { TextColorType } from "@noctaCrdt/Interfaces"; +import { css, cva } from "@styled-system/css"; + +type ColorVariants = { + [K in TextColorType]: { color: string }; +}; + +export const colorPaletteModal = css({ + zIndex: 1001, + borderRadius: "4px", + minWidth: "120px", // 3x3 그리드를 위한 최소 너비 + padding: "4px", + backgroundColor: "white", + boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)", +}); + +export const colorPaletteContainer = css({ + display: "grid", + gap: "4px", + gridTemplateColumns: "repeat(3, 1fr)", + width: "100%", +}); + +export const colorOptionButton = css({ + display: "flex", + justifyContent: "center", + alignItems: "center", + border: "none", + borderRadius: "4px", + width: "28px", + height: "28px", + margin: "0 2px", + padding: "2px", + transition: "transform 0.2s", + cursor: "pointer", + "&:hover": { + transform: "scale(1.1)", + }, +}); + +const colorVariants: ColorVariants = { + black: { color: "#2B4158" }, + red: { color: "#F24150" }, + green: { color: "#1BBF44" }, + blue: { color: "#4285F4" }, + yellow: { color: "#FEA642" }, + purple: { color: "#A142FE" }, + brown: { color: "#8B4513" }, + white: { color: "#C1D7F4" }, +}; + +export const textColorIndicator = cva({ + base: { + display: "flex", + justifyContent: "center", + alignItems: "center", + width: "100%", + height: "100%", + fontSize: "16px", + fontWeight: "bold", + }, + variants: { + color: colorVariants, + }, +}); diff --git a/client/src/features/editor/components/ColorOptionModal/TextColorOptionModal.tsx b/client/src/features/editor/components/ColorOptionModal/TextColorOptionModal.tsx new file mode 100644 index 00000000..c1e89dd4 --- /dev/null +++ b/client/src/features/editor/components/ColorOptionModal/TextColorOptionModal.tsx @@ -0,0 +1,48 @@ +import { TextColorType } from "@noctaCrdt/Interfaces"; +import { + colorOptionButton, + colorPaletteContainer, + colorPaletteModal, + textColorIndicator, +} from "./TextColorOptionModal.style.ts"; + +const COLORS: TextColorType[] = [ + "black", + "red", + "blue", + "green", + "yellow", + "purple", + "brown", + "white", +]; + +interface TextColorOptionModalProps { + onColorSelect: (color: TextColorType) => void; + position: { top: number; left: number }; +} + +export const TextColorOptionModal = ({ onColorSelect, position }: TextColorOptionModalProps) => { + return ( +
+
+ {COLORS.map((color) => ( + + ))} +
+
+ ); +}; diff --git a/client/src/features/editor/components/TextOptionModal/TextOptionModal.style.ts b/client/src/features/editor/components/TextOptionModal/TextOptionModal.style.ts index 18c42634..2242c885 100644 --- a/client/src/features/editor/components/TextOptionModal/TextOptionModal.style.ts +++ b/client/src/features/editor/components/TextOptionModal/TextOptionModal.style.ts @@ -1,4 +1,23 @@ -import { css } from "@styled-system/css"; +/* eslint-disable @pandacss/no-dynamic-styling */ +import { TextColorType } from "@noctaCrdt/Interfaces"; +import { colors } from "@src/styles/tokens/color"; +import { css, cva } from "@styled-system/css"; +import { token } from "@styled-system/tokens"; + +type ColorVariants = { + [K in TextColorType]: { color: string }; +}; + +const colorVariants: ColorVariants = { + black: { color: "#2B4158" }, + red: { color: "#F24150" }, + green: { color: "#1BBF44" }, + blue: { color: "#4285F4" }, + yellow: { color: "#FEA642" }, + purple: { color: "#A142FE" }, + brown: { color: "#8B4513" }, + white: { color: "#C1D7F4" }, +}; export const optionModal = css({ zIndex: 1000, @@ -30,3 +49,67 @@ export const optionButton = css({ background: "#e0e0e0", }, }); + +export const divider = css({ + width: "1px", + height: "20px", + margin: "0 8px", +}); + +export const colorOptionButton = css({ + width: "28px", + height: "28px", + margin: "0 2px", + cursor: "pointer", + _hover: { + transform: "scale(1.1)", + }, +}); + +// 색상 표시 원형 스타일 베이스 +const colorIndicatorBase = { + width: "28px", + height: "28px", + borderRadius: "3px", + transition: "all 0.2s", +}; + +// 텍스트 색상 indicator +export const textColorIndicator = cva({ + ...colorIndicatorBase, + variants: { + color: colorVariants, + }, +}); + +export const backgroundColorIndicator = cva({ + ...colorIndicatorBase, + variants: { + color: { + black: { + backgroundColor: `color-mix(in srgb, ${token("colors.gray.900")}, white 20%)`, + }, + red: { + backgroundColor: `color-mix(in srgb, ${token("colors.red")}, white 20%)`, + }, + yellow: { + backgroundColor: `color-mix(in srgb, ${token("colors.yellow")}, white 20%)`, + }, + green: { + backgroundColor: `color-mix(in srgb, ${token("colors.green")}, white 20%)`, + }, + purple: { + backgroundColor: `color-mix(in srgb, ${token("colors.purple")}, white 20%)`, + }, + brown: { + backgroundColor: `color-mix(in srgb, ${token("colors.brown")}, white 20%)`, + }, + blue: { + backgroundColor: `color-mix(in srgb, ${token("colors.blue")}, white 20%)`, + }, + white: { + backgroundColor: token("colors.white"), + }, + }, + }, +}); diff --git a/client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx b/client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx index 3312dda4..f69351e9 100644 --- a/client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx +++ b/client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx @@ -1,8 +1,31 @@ +import { TextColorType, BackgroundColorType } from "@noctaCrdt/Interfaces"; import { Char } from "@noctaCrdt/Node"; import { motion } from "framer-motion"; import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { modalContainer, optionButton, optionModal } from "./TextOptionModal.style"; +import { BackgroundColorOptionModal } from "../ColorOptionModal/BackgroundColorOptionModal"; +import { TextColorOptionModal } from "../ColorOptionModal/TextColorOptionModal"; +import { + modalContainer, + optionButton, + optionModal, + divider, + textColorIndicator, + backgroundColorIndicator, + colorOptionButton, +} from "./TextOptionModal.style"; + +// 사용 가능한 색상 배열 +const COLORS: TextColorType[] = [ + "black", + "red", + "green", + "blue", + "yellow", + "purple", + "brown", + "white", +]; interface SelectionModalProps { selectedNodes: Array | null; @@ -12,6 +35,8 @@ interface SelectionModalProps { onUnderlineSelect: () => void; onStrikeSelect: () => void; onClose: () => void; + onTextColorSelect: (color: TextColorType) => void; + onTextBackgroundColorSelect: (color: BackgroundColorType) => void; } const ModalPortal = ({ children }: { children: React.ReactNode }) => { @@ -26,12 +51,15 @@ export const TextOptionModal = ({ onUnderlineSelect, onStrikeSelect, onClose, + onTextColorSelect, + onTextBackgroundColorSelect, }: SelectionModalProps) => { const modalRef = useRef(null); const [TextModalPosition, setTextModalPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0, }); + const [hoveredType, setHoveredType] = useState<"text" | "background" | null>(null); const [styleState, setStyleState] = useState({ isBold: false, isItalic: false, @@ -39,6 +67,24 @@ export const TextOptionModal = ({ isStrike: false, }); + const handleMouseEnter = (type: "text" | "background") => { + setHoveredType(type); + }; + + const handleClickButton = (type: "text" | "background") => { + if (hoveredType === type) { + setHoveredType(null); + } else { + setHoveredType(type); + } + }; + + const handleModalClick = () => { + if (hoveredType !== null) { + setHoveredType(null); + } + }; + useEffect(() => { if (!isOpen) return; @@ -75,6 +121,7 @@ export const TextOptionModal = ({ isUnderline: false, isStrike: false, }); + setHoveredType(null); }; }, [isOpen, onClose]); @@ -119,6 +166,16 @@ export const TextOptionModal = ({ }); }, [selectedNodes]); + const handleTextColorClick = (color: TextColorType) => { + if (!selectedNodes || selectedNodes.length === 0) return; + onTextColorSelect(color); + }; + + const handleTextBackgroundSelect = (color: BackgroundColorType) => { + if (!selectedNodes || selectedNodes.length === 0) return; + onTextBackgroundColorSelect(color); + }; + if (!isOpen) return; return ( @@ -128,13 +185,13 @@ export const TextOptionModal = ({ className={optionModal} style={{ left: `${TextModalPosition.left}px`, - top: `${TextModalPosition.top}px`, + top: `${TextModalPosition.top - 8}px`, }} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 10 }} > -
+
+ + {/** Divider: 왼쪽에 텍스트 스타일, 오른쪽에 색상 */} + +
+ + {/* 텍스트 색상 버튼들 */} +
handleMouseEnter("text")} + onClick={(e) => handleClickButton("text")} + > + + {hoveredType === "text" && ( + + )} +
+ +
+ + {/* 배경 색상 버튼들 */} +
handleMouseEnter("background")} + onClick={() => handleClickButton("background")} + > + + + {hoveredType === "background" && ( + + )} +
From c22551b2bb5308f0a6915be0cdf4706bfa4971d5 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 20:16:06 +0900 Subject: [PATCH 36/47] =?UTF-8?q?chore:=20lint=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TextOptionModal/TextOptionModal.style.ts | 79 +------------------ .../TextOptionModal/TextOptionModal.tsx | 24 +----- 2 files changed, 3 insertions(+), 100 deletions(-) diff --git a/client/src/features/editor/components/TextOptionModal/TextOptionModal.style.ts b/client/src/features/editor/components/TextOptionModal/TextOptionModal.style.ts index 2242c885..977a85b3 100644 --- a/client/src/features/editor/components/TextOptionModal/TextOptionModal.style.ts +++ b/client/src/features/editor/components/TextOptionModal/TextOptionModal.style.ts @@ -1,23 +1,4 @@ -/* eslint-disable @pandacss/no-dynamic-styling */ -import { TextColorType } from "@noctaCrdt/Interfaces"; -import { colors } from "@src/styles/tokens/color"; -import { css, cva } from "@styled-system/css"; -import { token } from "@styled-system/tokens"; - -type ColorVariants = { - [K in TextColorType]: { color: string }; -}; - -const colorVariants: ColorVariants = { - black: { color: "#2B4158" }, - red: { color: "#F24150" }, - green: { color: "#1BBF44" }, - blue: { color: "#4285F4" }, - yellow: { color: "#FEA642" }, - purple: { color: "#A142FE" }, - brown: { color: "#8B4513" }, - white: { color: "#C1D7F4" }, -}; +import { css } from "@styled-system/css"; export const optionModal = css({ zIndex: 1000, @@ -55,61 +36,3 @@ export const divider = css({ height: "20px", margin: "0 8px", }); - -export const colorOptionButton = css({ - width: "28px", - height: "28px", - margin: "0 2px", - cursor: "pointer", - _hover: { - transform: "scale(1.1)", - }, -}); - -// 색상 표시 원형 스타일 베이스 -const colorIndicatorBase = { - width: "28px", - height: "28px", - borderRadius: "3px", - transition: "all 0.2s", -}; - -// 텍스트 색상 indicator -export const textColorIndicator = cva({ - ...colorIndicatorBase, - variants: { - color: colorVariants, - }, -}); - -export const backgroundColorIndicator = cva({ - ...colorIndicatorBase, - variants: { - color: { - black: { - backgroundColor: `color-mix(in srgb, ${token("colors.gray.900")}, white 20%)`, - }, - red: { - backgroundColor: `color-mix(in srgb, ${token("colors.red")}, white 20%)`, - }, - yellow: { - backgroundColor: `color-mix(in srgb, ${token("colors.yellow")}, white 20%)`, - }, - green: { - backgroundColor: `color-mix(in srgb, ${token("colors.green")}, white 20%)`, - }, - purple: { - backgroundColor: `color-mix(in srgb, ${token("colors.purple")}, white 20%)`, - }, - brown: { - backgroundColor: `color-mix(in srgb, ${token("colors.brown")}, white 20%)`, - }, - blue: { - backgroundColor: `color-mix(in srgb, ${token("colors.blue")}, white 20%)`, - }, - white: { - backgroundColor: token("colors.white"), - }, - }, - }, -}); diff --git a/client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx b/client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx index f69351e9..9996e17b 100644 --- a/client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx +++ b/client/src/features/editor/components/TextOptionModal/TextOptionModal.tsx @@ -5,27 +5,7 @@ import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { BackgroundColorOptionModal } from "../ColorOptionModal/BackgroundColorOptionModal"; import { TextColorOptionModal } from "../ColorOptionModal/TextColorOptionModal"; -import { - modalContainer, - optionButton, - optionModal, - divider, - textColorIndicator, - backgroundColorIndicator, - colorOptionButton, -} from "./TextOptionModal.style"; - -// 사용 가능한 색상 배열 -const COLORS: TextColorType[] = [ - "black", - "red", - "green", - "blue", - "yellow", - "purple", - "brown", - "white", -]; +import { modalContainer, optionButton, optionModal, divider } from "./TextOptionModal.style"; interface SelectionModalProps { selectedNodes: Array | null; @@ -268,7 +248,7 @@ export const TextOptionModal = ({
handleMouseEnter("text")} - onClick={(e) => handleClickButton("text")} + onClick={() => handleClickButton("text")} > - - {/** Divider: 왼쪽에 텍스트 스타일, 오른쪽에 색상 */} - -
- {/* 텍스트 색상 버튼들 */}
handleMouseEnter("text")} onClick={() => handleClickButton("text")} > - + A {hoveredType === "text" && ( )}
- -
- {/* 배경 색상 버튼들 */}
handleMouseEnter("background")} onClick={() => handleClickButton("background")} > - + BG {hoveredType === "background" && ( Date: Mon, 25 Nov 2024 21:39:17 +0900 Subject: [PATCH 38/47] =?UTF-8?q?feat:=20=EB=B8=94=EB=A1=9D=20=EB=B3=B5?= =?UTF-8?q?=EC=A0=9C,=20=EB=B3=91=ED=95=A9,=20=EB=B6=84=ED=95=A0=20?= =?UTF-8?q?=ED=95=A0=EB=95=8C=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EA=B0=99?= =?UTF-8?q?=EC=9D=B4=20=EC=A0=81=EC=9A=A9=EB=90=98=EA=B2=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- @noctaCrdt/Crdt.ts | 10 ++++++++++ client/src/features/editor/hooks/useBlockOption.ts | 3 +++ client/src/features/editor/hooks/useMarkdownGrammer.ts | 4 ++++ 3 files changed, 17 insertions(+) diff --git a/@noctaCrdt/Crdt.ts b/@noctaCrdt/Crdt.ts index ab39b149..4beac4f2 100644 --- a/@noctaCrdt/Crdt.ts +++ b/@noctaCrdt/Crdt.ts @@ -10,6 +10,8 @@ import { RemoteBlockReorderOperation, RemoteBlockUpdateOperation, RemoteCharUpdateOperation, + TextColorType, + BackgroundColorType, } from "./Interfaces"; export class CRDT> { @@ -219,12 +221,20 @@ export class BlockCRDT extends CRDT { blockId: BlockId, pageId: string, style?: string[], + color?: TextColorType, + backgroundColor?: BackgroundColorType, ): RemoteCharInsertOperation { const id = new CharId(this.clock + 1, this.client); const { node } = this.LinkedList.insertAtIndex(index, value, id) as { node: Char }; if (style && style.length > 0) { node.style = style; } + if (color) { + node.color = color; + } + if (backgroundColor) { + node.backgroundColor = backgroundColor; + } this.clock += 1; const operation: RemoteCharInsertOperation = { node, diff --git a/client/src/features/editor/hooks/useBlockOption.ts b/client/src/features/editor/hooks/useBlockOption.ts index 35a6799c..7f294410 100644 --- a/client/src/features/editor/hooks/useBlockOption.ts +++ b/client/src/features/editor/hooks/useBlockOption.ts @@ -102,6 +102,9 @@ export const useBlockOptionSelect = ({ operation.node.id, pageId, ); + insertOperation.node.style = char.style; + insertOperation.node.color = char.color; + insertOperation.node.backgroundColor = char.backgroundColor; sendCharInsertOperation(insertOperation); }); diff --git a/client/src/features/editor/hooks/useMarkdownGrammer.ts b/client/src/features/editor/hooks/useMarkdownGrammer.ts index 7ba6b815..cca1b4d7 100644 --- a/client/src/features/editor/hooks/useMarkdownGrammer.ts +++ b/client/src/features/editor/hooks/useMarkdownGrammer.ts @@ -117,6 +117,8 @@ export const useMarkdownGrammer = ({ operation.node.id, pageId, currentCharNode.style, + currentCharNode.color, + currentCharNode.backgroundColor, ), ); }); @@ -199,6 +201,8 @@ export const useMarkdownGrammer = ({ prevBlock.id, pageId, currentCharNode.style, + currentCharNode.color, + currentCharNode.backgroundColor, ), ); } From e66fd217f6c6f413c578e36ac42e1c77d03e9ba3 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 25 Nov 2024 22:37:14 +0900 Subject: [PATCH 39/47] =?UTF-8?q?design:=20=EC=A0=9C=EB=AA=A9=EA=B3=BC=20?= =?UTF-8?q?=EC=97=90=EB=94=94=ED=84=B0=20=EC=82=AC=EC=9D=B4=EC=97=90=20?= =?UTF-8?q?=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/editor/Editor.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index 2f9ef559..afda7ad2 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -319,6 +319,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onChange={handleTitleChange} className={editorTitle} /> +
Date: Tue, 26 Nov 2024 00:49:25 +0900 Subject: [PATCH 40/47] =?UTF-8?q?fix:=20=EC=84=9C=EC=9C=A4=EB=8B=98=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/editor/components/block/Block.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index a4fc6d07..8bd275ba 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -169,11 +169,10 @@ export const Block: React.FC = memo( const handleStyleSelect = (styleType: TextStyleType) => { if (blockRef.current && selectedNodes) { - const selection = window.getSelection(); // CRDT 상태 업데이트 및 서버 전송 onTextStyleUpdate(styleType, block.id, selectedNodes); - const position = selection?.focusOffset || 0; + const position = getAbsoluteCaretPosition(blockRef.current); block.crdt.currentCaret = position; closeModal(); @@ -182,10 +181,9 @@ export const Block: React.FC = memo( const handleTextColorSelect = (color: TextColorType) => { if (blockRef.current && selectedNodes) { - const selection = window.getSelection(); onTextColorUpdate(color, block.id, selectedNodes); - const position = selection?.focusOffset || 0; + const position = getAbsoluteCaretPosition(blockRef.current); block.crdt.currentCaret = position; closeModal(); @@ -194,10 +192,9 @@ export const Block: React.FC = memo( const handleTextBackgroundColorSelect = (color: BackgroundColorType) => { if (blockRef.current && selectedNodes) { - const selection = window.getSelection(); onTextBackgroundColorUpdate(color, block.id, selectedNodes); - const position = selection?.focusOffset || 0; + const position = getAbsoluteCaretPosition(blockRef.current); block.crdt.currentCaret = position; closeModal(); From bfa4393dc30b3b81f788fb10302dc59d476d427b Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Tue, 26 Nov 2024 01:22:06 +0900 Subject: [PATCH 41/47] =?UTF-8?q?feat:=20=EC=9D=BC=EB=B0=98=20=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B6=99=EC=97=AC=EB=84=A3=EA=B8=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/editor/Editor.tsx | 29 +++++++++++++++++++ .../editor/components/block/Block.tsx | 3 ++ 2 files changed, 32 insertions(+) diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index afda7ad2..ef7be75d 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -171,6 +171,34 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr [sendCharInsertOperation, sendCharDeleteOperation], ); + const handlePaste = (e: React.ClipboardEvent, block: CRDTBlock) => { + e.preventDefault(); + const text = e.clipboardData.getData("text/plain"); + + if (!block || text.length === 0) return; + + const caretPosition = block.crdt.currentCaret; + + // 텍스트를 한 글자씩 순차적으로 삽입 + text.split("").forEach((char, index) => { + const insertPosition = caretPosition + index; + const charNode = block.crdt.localInsert(insertPosition, char, block.id, pageId); + sendCharInsertOperation({ + node: charNode.node, + blockId: block.id, + pageId, + }); + }); + + // 캐럿 위치 업데이트 + editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition + text.length; + + setEditorState({ + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, + }); + }; + const handleCompositionEnd = (e: React.CompositionEvent, block: CRDTBlock) => { const event = e.nativeEvent as CompositionEvent; const characters = [...event.data]; @@ -336,6 +364,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onInput={handleBlockInput} onCompositionEnd={handleCompositionEnd} onKeyDown={handleKeyDown} + onPaste={handlePaste} onClick={handleBlockClick} onAnimationSelect={handleAnimationSelect} onTypeSelect={handleTypeSelect} diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index 8bd275ba..ea47bc47 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -28,6 +28,7 @@ interface BlockProps { onInput: (e: React.FormEvent, block: CRDTBlock) => void; onCompositionEnd: (e: React.CompositionEvent, block: CRDTBlock) => void; onKeyDown: (e: React.KeyboardEvent) => void; + onPaste: (e: React.ClipboardEvent, block: CRDTBlock) => void; onClick: (blockId: BlockId, e: React.MouseEvent) => void; onAnimationSelect: (blockId: BlockId, animation: AnimationType) => void; onTypeSelect: (blockId: BlockId, type: ElementType) => void; @@ -53,6 +54,7 @@ export const Block: React.FC = memo( onInput, onCompositionEnd, onKeyDown, + onPaste, onClick, onAnimationSelect, onTypeSelect, @@ -241,6 +243,7 @@ export const Block: React.FC = memo( onKeyDown={onKeyDown} onInput={handleInput} onClick={(e) => onClick(block.id, e)} + onPaste={(e) => onPaste(e, block)} onMouseUp={handleMouseUp} onCompositionEnd={(e) => onCompositionEnd(e, block)} contentEditable From f153fe7349ac76ad1cdff4a8b798d87a1304f4b2 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Tue, 26 Nov 2024 01:49:55 +0900 Subject: [PATCH 42/47] =?UTF-8?q?feat:=20=EC=84=9C=EB=B2=84=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=9B=EC=9D=80=20char=20=EC=82=BD=EC=9E=85=EC=8B=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=A0=81=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- @noctaCrdt/Crdt.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/@noctaCrdt/Crdt.ts b/@noctaCrdt/Crdt.ts index 4beac4f2..737ad9fe 100644 --- a/@noctaCrdt/Crdt.ts +++ b/@noctaCrdt/Crdt.ts @@ -296,6 +296,14 @@ export class BlockCRDT extends CRDT { }); } + if (operation.color) { + newNode.color = operation.color; + } + + if (operation.backgroundColor) { + newNode.backgroundColor = operation.backgroundColor; + } + this.LinkedList.insertById(newNode); if (this.clock <= newNode.id.clock) { From 8a28c17e26588c409bb636905a198db9ee6aa9a7 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Tue, 26 Nov 2024 01:50:12 +0900 Subject: [PATCH 43/47] =?UTF-8?q?feat:=20remoteCharInsertOperation?= =?UTF-8?q?=EC=97=90=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=86=8D=EC=84=B1=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 --- @noctaCrdt/Interfaces.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/@noctaCrdt/Interfaces.ts b/@noctaCrdt/Interfaces.ts index 1a4884a0..52c785d1 100644 --- a/@noctaCrdt/Interfaces.ts +++ b/@noctaCrdt/Interfaces.ts @@ -52,6 +52,8 @@ export interface RemoteCharInsertOperation { blockId: BlockId; pageId: string; style?: string[]; + color?: TextColorType; + backgroundColor?: BackgroundColorType; } export interface RemoteBlockDeleteOperation { From 1e874e277aee6bad6344b62a0f3437bd2dd8c41e Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Tue, 26 Nov 2024 01:50:33 +0900 Subject: [PATCH 44/47] =?UTF-8?q?feat:=20=EC=84=9C=EB=B2=84=20=EC=9D=B8?= =?UTF-8?q?=EC=8A=A4=ED=84=B4=EC=8A=A4=EC=97=90=EC=84=9C=20operation=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/crdt/crdt.gateway.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/crdt/crdt.gateway.ts b/server/src/crdt/crdt.gateway.ts index 48d7c229..468f35c2 100644 --- a/server/src/crdt/crdt.gateway.ts +++ b/server/src/crdt/crdt.gateway.ts @@ -277,6 +277,8 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa blockId: data.blockId, pageId: data.pageId, style: data.style || [], + color: data.color ? data.color : "black", + backgroundColor: data.backgroundColor ? data.backgroundColor : "transparent", }; client.broadcast.emit("insert/char", operation); } catch (error) { From 97e50933346d7032d42005f5a4ee0eacf6c605bc Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Tue, 26 Nov 2024 01:51:06 +0900 Subject: [PATCH 45/47] =?UTF-8?q?feat:=20getTextOffset=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=ED=95=A8=EC=88=98=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/components/block/Block.tsx | 31 ++--- .../src/features/editor/utils/domSyncUtils.ts | 111 ++++-------------- 2 files changed, 31 insertions(+), 111 deletions(-) diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index ea47bc47..075abf32 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -14,7 +14,7 @@ import { memo, useEffect, useRef, useState } from "react"; import { useModal } from "@src/components/modal/useModal"; import { getAbsoluteCaretPosition } from "@src/utils/caretUtils"; import { useBlockAnimation } from "../../hooks/useBlockAnimtaion"; -import { setInnerHTML } from "../../utils/domSyncUtils"; +import { setInnerHTML, getTextOffset } from "../../utils/domSyncUtils"; import { IconBlock } from "../IconBlock/IconBlock"; import { MenuBlock } from "../MenuBlock/MenuBlock"; import { TextOptionModal } from "../TextOptionModal/TextOptionModal"; @@ -28,6 +28,11 @@ interface BlockProps { onInput: (e: React.FormEvent, block: CRDTBlock) => void; onCompositionEnd: (e: React.CompositionEvent, block: CRDTBlock) => void; onKeyDown: (e: React.KeyboardEvent) => void; + onCopy: ( + e: React.ClipboardEvent, + blockRef: HTMLDivElement | null, + block: CRDTBlock, + ) => void; onPaste: (e: React.ClipboardEvent, block: CRDTBlock) => void; onClick: (blockId: BlockId, e: React.MouseEvent) => void; onAnimationSelect: (blockId: BlockId, animation: AnimationType) => void; @@ -54,6 +59,7 @@ export const Block: React.FC = memo( onInput, onCompositionEnd, onKeyDown, + onCopy, onPaste, onClick, onAnimationSelect, @@ -139,26 +145,8 @@ export const Block: React.FC = memo( return; } - // 실제 텍스트 위치 계산 - const getTextOffset = (container: Node, offset: number): number => { - let totalOffset = 0; - const walker = document.createTreeWalker(blockRef.current!, NodeFilter.SHOW_TEXT, null); - - let node = walker.nextNode(); - while (node) { - if (node === container) { - return totalOffset + offset; - } - if (node.compareDocumentPosition(container) & Node.DOCUMENT_POSITION_FOLLOWING) { - totalOffset += node.textContent?.length || 0; - } - node = walker.nextNode(); - } - return totalOffset; - }; - - const startOffset = getTextOffset(range.startContainer, range.startOffset); - const endOffset = getTextOffset(range.endContainer, range.endOffset); + const startOffset = getTextOffset(blockRef.current, range.startContainer, range.startOffset); + const endOffset = getTextOffset(blockRef.current, range.endContainer, range.endOffset); const nodes = block.crdt.LinkedList.spread().slice(startOffset, endOffset); console.log("nodes", nodes); @@ -243,6 +231,7 @@ export const Block: React.FC = memo( onKeyDown={onKeyDown} onInput={handleInput} onClick={(e) => onClick(block.id, e)} + onCopy={(e) => onCopy(e, blockRef.current, block)} onPaste={(e) => onPaste(e, block)} onMouseUp={handleMouseUp} onCompositionEnd={(e) => onCompositionEnd(e, block)} diff --git a/client/src/features/editor/utils/domSyncUtils.ts b/client/src/features/editor/utils/domSyncUtils.ts index d9d031ac..2260af65 100644 --- a/client/src/features/editor/utils/domSyncUtils.ts +++ b/client/src/features/editor/utils/domSyncUtils.ts @@ -129,96 +129,6 @@ export const setInnerHTML = ({ element, block }: SetInnerHTMLProps): void => { } }; -/* -const getClassNames = (styles: Set): string => { - // underline과 strikethrough가 함께 있는 경우 특별 처리 - if (styles.has("underline") && styles.has("strikethrough")) { - return css({ - textDecoration: "underline line-through", - fontWeight: styles.has("bold") ? "bold" : "normal", - fontStyle: styles.has("italic") ? "italic" : "normal", - }); - } - - // 일반적인 경우 - return css({ - textDecoration: styles.has("underline") - ? "underline" - : styles.has("strikethrough") - ? "line-through" - : "none", - // textStyle 속성 대신 직접 스타일 지정 - fontWeight: styles.has("bold") ? "bold" : "normal", - fontStyle: styles.has("italic") ? "italic" : "normal", - }); -}; -*/ - -/* -export const setInnerHTML = ({ element, block }: SetInnerHTMLProps): void => { - const chars = block.crdt.LinkedList.spread(); - if (chars.length === 0) { - element.innerHTML = ""; - return; - } - - // 각 위치별 모든 적용된 스타일을 추적 - const positionStyles: Set[] = chars.map((_, index) => { - const styleSet = new Set(); - - // 해당 위치에 적용되어야 하는 모든 스타일 수집 - chars.forEach((c, i) => { - if (i === index) { - c.style.forEach((style) => styleSet.add(TEXT_STYLES[style])); - } - }); - - return styleSet; - }); - - let html = ""; - let currentStyles = new Set(); - let spanOpen = false; - - chars.forEach((char, index) => { - const targetStyles = positionStyles[index]; - - // 스타일이 변경되었는지 확인 - const styleChanged = !setsEqual(currentStyles, targetStyles); - - // 스타일이 변경되었으면 현재 span 태그 닫기 - if (styleChanged && spanOpen) { - html += ""; - spanOpen = false; - } - - // 새로운 스타일 조합으로 span 태그 열기 - if (styleChanged && targetStyles.size > 0) { - const className = getClassNames(targetStyles); - html += ``; - spanOpen = true; - } - - // 텍스트 추가 - html += sanitizeText(char.value); - - // 다음 문자로 넘어가기 전에 현재 스타일 상태 업데이트 - currentStyles = targetStyles; - - // 마지막 문자이고 span이 열려있으면 닫기 - if (index === chars.length - 1 && spanOpen) { - html += ""; - spanOpen = false; - } - }); - - // DOM 업데이트 - if (element.innerHTML !== html) { - element.innerHTML = html; - } -}; -*/ - // Set 비교 헬퍼 함수 const setsEqual = (a: Set, b: Set): boolean => { if (a.size !== b.size) return false; @@ -243,3 +153,24 @@ export const arraysEqual = (a: string[], b: string[]): boolean => { if (a.length !== b.length) return false; return a.sort().every((val, idx) => val === b.sort()[idx]); }; + +export const getTextOffset = ( + blockRef: HTMLDivElement, + container: Node, + offset: number, +): number => { + let totalOffset = 0; + const walker = document.createTreeWalker(blockRef, NodeFilter.SHOW_TEXT, null); + + let node = walker.nextNode(); + while (node) { + if (node === container) { + return totalOffset + offset; + } + if (node.compareDocumentPosition(container) & Node.DOCUMENT_POSITION_FOLLOWING) { + totalOffset += node.textContent?.length || 0; + } + node = walker.nextNode(); + } + return totalOffset; +}; From 4e438004aab3e7b34fc4406514b173072b01e971 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Tue, 26 Nov 2024 01:52:19 +0900 Subject: [PATCH 46/47] =?UTF-8?q?feat:=20=EB=B3=B5=EC=82=AC=20=EB=B6=99?= =?UTF-8?q?=EC=97=AC=EB=84=A3=EA=B8=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일반 텍스트 복사 - 스타일 있는 텍스트 복사시 클립보드에 커스텀 데이터 구조로 저장 - 붙여넣기 시 커스텀 데이터 없으면 일반 글자 처리 - 커스텀 데이터 있으면 해당 스타일 모두 적용해서 붙여넣기 --- client/src/features/editor/Editor.tsx | 117 ++++++++++++++++++++++---- 1 file changed, 100 insertions(+), 17 deletions(-) diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index ef7be75d..a9be29b1 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -7,6 +7,8 @@ import { BlockId } from "@noctaCrdt/NodeId"; import { RemoteCharInsertOperation, serializedEditorDataProps, + TextColorType, + BackgroundColorType, } from "node_modules/@noctaCrdt/Interfaces.ts"; import { useRef, useState, useCallback, useEffect, useMemo } from "react"; import { useSocketStore } from "@src/stores/useSocketStore.ts"; @@ -22,6 +24,12 @@ import { useBlockDragAndDrop } from "./hooks/useBlockDragAndDrop"; import { useBlockOptionSelect } from "./hooks/useBlockOption.ts"; import { useMarkdownGrammer } from "./hooks/useMarkdownGrammer"; import { useTextOptionSelect } from "./hooks/useTextOptions.ts"; +import { getTextOffset } from "./utils/domSyncUtils.ts"; + +export interface EditorStateProps { + clock: number; + linkedList: BlockLinkedList; +} interface EditorProps { onTitleChange: (title: string) => void; @@ -29,10 +37,13 @@ interface EditorProps { serializedEditorData: serializedEditorDataProps; } -export interface EditorStateProps { - clock: number; - linkedList: BlockLinkedList; +interface ClipboardMetadata { + value: string; + style: string[]; + color: TextColorType | undefined; + backgroundColor: BackgroundColorType | undefined; } + // TODO: pageId, editorCRDT를 props로 받아와야함 export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorProps) => { const { @@ -171,27 +182,98 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr [sendCharInsertOperation, sendCharDeleteOperation], ); + const handleCopy = ( + e: React.ClipboardEvent, + blockRef: HTMLDivElement | null, + block: CRDTBlock, + ) => { + e.preventDefault(); + if (!blockRef) return; + + const selection = window.getSelection(); + if (!selection || selection.isCollapsed || !blockRef) return; + + const range = selection.getRangeAt(0); + if (!blockRef.contains(range.commonAncestorContainer)) return; + + // 선택된 텍스트의 시작과 끝 위치 계산 + const startOffset = getTextOffset(blockRef, range.startContainer, range.startOffset); + const endOffset = getTextOffset(blockRef, range.endContainer, range.endOffset); + + // 선택된 텍스트와 스타일 정보 추출 + const selectedChars = block.crdt.LinkedList.spread().slice(startOffset, endOffset); + + // 커스텀 데이터 포맷으로 저장 + const customData = { + text: selectedChars.map((char) => char.value).join(""), + metadata: selectedChars.map( + (char) => + ({ + value: char.value, + style: char.style, + color: char.color, + backgroundColor: char.backgroundColor, + }) as ClipboardMetadata, + ), + }; + + // 일반 텍스트와 커스텀 데이터 모두 클립보드에 저장 + e.clipboardData.setData("text/plain", customData.text); + e.clipboardData.setData("application/x-nocta-formatted", JSON.stringify(customData)); + }; + const handlePaste = (e: React.ClipboardEvent, block: CRDTBlock) => { e.preventDefault(); - const text = e.clipboardData.getData("text/plain"); - if (!block || text.length === 0) return; + const customData = e.clipboardData.getData("application/x-nocta-formatted"); + + if (customData) { + const { metadata } = JSON.parse(customData); + const caretPosition = block.crdt.currentCaret; + + metadata.forEach((char: ClipboardMetadata, index: number) => { + const insertPosition = caretPosition + index; + const charNode = block.crdt.localInsert( + insertPosition, + char.value, + block.id, + pageId, + char.style, + char.color, + char.backgroundColor, + ); + sendCharInsertOperation({ + node: charNode.node, + blockId: block.id, + pageId, + style: char.style, + color: char.color, + backgroundColor: char.backgroundColor, + }); + }); - const caretPosition = block.crdt.currentCaret; + editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition + metadata.length; + } else { + const text = e.clipboardData.getData("text/plain"); - // 텍스트를 한 글자씩 순차적으로 삽입 - text.split("").forEach((char, index) => { - const insertPosition = caretPosition + index; - const charNode = block.crdt.localInsert(insertPosition, char, block.id, pageId); - sendCharInsertOperation({ - node: charNode.node, - blockId: block.id, - pageId, + if (!block || text.length === 0) return; + + const caretPosition = block.crdt.currentCaret; + + // 텍스트를 한 글자씩 순차적으로 삽입 + text.split("").forEach((char, index) => { + const insertPosition = caretPosition + index; + const charNode = block.crdt.localInsert(insertPosition, char, block.id, pageId); + sendCharInsertOperation({ + node: charNode.node, + blockId: block.id, + pageId, + }); }); - }); - // 캐럿 위치 업데이트 - editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition + text.length; + // 캐럿 위치 업데이트 + editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition + text.length; + } setEditorState({ clock: editorCRDT.current.clock, @@ -364,6 +446,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onInput={handleBlockInput} onCompositionEnd={handleCompositionEnd} onKeyDown={handleKeyDown} + onCopy={handleCopy} onPaste={handlePaste} onClick={handleBlockClick} onAnimationSelect={handleAnimationSelect} From 19e9844d10b2d67e87dc7dceff3049da3239d37f Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Tue, 26 Nov 2024 02:49:36 +0900 Subject: [PATCH 47/47] =?UTF-8?q?feat:=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8=ED=95=9C=20=ED=9B=84=20=EB=B0=B1?= =?UTF-8?q?=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20=ED=81=B4=EB=A6=AD?= =?UTF-8?q?=EC=8B=9C=20=ED=95=9C=EB=B2=88=EC=97=90=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/editor/Editor.tsx | 40 ++++++++++++++++++- .../editor/components/block/Block.tsx | 8 +++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index a9be29b1..962c29ff 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -84,7 +84,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr sendCharInsertOperation, }); - const { handleKeyDown } = useMarkdownGrammer({ + const { handleKeyDown: onKeyDown } = useMarkdownGrammer({ editorCRDT: editorCRDT.current, editorState, setEditorState, @@ -182,6 +182,44 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr [sendCharInsertOperation, sendCharDeleteOperation], ); + const handleKeyDown = ( + e: React.KeyboardEvent, + blockRef: HTMLDivElement | null, + block: CRDTBlock, + ) => { + if (!blockRef || !block) return; + const selection = window.getSelection(); + if (!selection || selection.isCollapsed || !blockRef) { + // 선택된 텍스트가 없으면 기존 onKeyDown 로직 실행 + onKeyDown(e); + return; + } + + if (e.key === "Backspace") { + e.preventDefault(); + + const range = selection.getRangeAt(0); + if (!blockRef.contains(range.commonAncestorContainer)) return; + + const startOffset = getTextOffset(blockRef, range.startContainer, range.startOffset); + const endOffset = getTextOffset(blockRef, range.endContainer, range.endOffset); + + // 선택된 범위의 문자들을 역순으로 삭제 + for (let i = endOffset - 1; i >= startOffset; i--) { + const operationNode = block.crdt.localDelete(i, block.id, pageId); + sendCharDeleteOperation(operationNode); + } + + block.crdt.currentCaret = startOffset; + setEditorState({ + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, + }); + } else { + onKeyDown(e); + } + }; + const handleCopy = ( e: React.ClipboardEvent, blockRef: HTMLDivElement | null, diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index 075abf32..461cb88c 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -27,7 +27,11 @@ interface BlockProps { isActive: boolean; onInput: (e: React.FormEvent, block: CRDTBlock) => void; onCompositionEnd: (e: React.CompositionEvent, block: CRDTBlock) => void; - onKeyDown: (e: React.KeyboardEvent) => void; + onKeyDown: ( + e: React.KeyboardEvent, + blockRef: HTMLDivElement | null, + block: CRDTBlock, + ) => void; onCopy: ( e: React.ClipboardEvent, blockRef: HTMLDivElement | null, @@ -228,7 +232,7 @@ export const Block: React.FC = memo(
onKeyDown(e, blockRef.current, block)} onInput={handleInput} onClick={(e) => onClick(block.id, e)} onCopy={(e) => onCopy(e, blockRef.current, block)}