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) { diff --git a/@noctaCrdt/Interfaces.ts b/@noctaCrdt/Interfaces.ts index 4062ef9f..9d200a4a 100644 --- a/@noctaCrdt/Interfaces.ts +++ b/@noctaCrdt/Interfaces.ts @@ -58,6 +58,8 @@ export interface RemoteCharInsertOperation { blockId: BlockId; pageId: string; style?: string[]; + color?: TextColorType; + backgroundColor?: BackgroundColorType; } export interface RemoteBlockDeleteOperation { diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index afda7ad2..962c29ff 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 { @@ -73,7 +84,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr sendCharInsertOperation, }); - const { handleKeyDown } = useMarkdownGrammer({ + const { handleKeyDown: onKeyDown } = useMarkdownGrammer({ editorCRDT: editorCRDT.current, editorState, setEditorState, @@ -171,6 +182,143 @@ 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, + 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 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, + }); + }); + + editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition + metadata.length; + } else { + 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 +484,8 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onInput={handleBlockInput} onCompositionEnd={handleCompositionEnd} onKeyDown={handleKeyDown} + onCopy={handleCopy} + 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 a4fc6d07..461cb88c 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"; @@ -27,7 +27,17 @@ 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, + block: CRDTBlock, + ) => 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 +63,8 @@ export const Block: React.FC = memo( onInput, onCompositionEnd, onKeyDown, + onCopy, + onPaste, onClick, onAnimationSelect, onTypeSelect, @@ -137,26 +149,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); @@ -169,11 +163,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 +175,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 +186,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(); @@ -241,9 +232,11 @@ 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)} + onPaste={(e) => onPaste(e, block)} onMouseUp={handleMouseUp} onCompositionEnd={(e) => onCompositionEnd(e, block)} contentEditable 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; +}; diff --git a/server/src/crdt/crdt.gateway.ts b/server/src/crdt/crdt.gateway.ts index 5049e35b..b623a7fb 100644 --- a/server/src/crdt/crdt.gateway.ts +++ b/server/src/crdt/crdt.gateway.ts @@ -326,6 +326,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) {