From fb88335df38350df714cb91dc6f9521ae798ed41 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Tue, 26 Nov 2024 00:49:25 +0900 Subject: [PATCH 1/8] =?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 2/8] =?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 3/8] =?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 4/8] =?UTF-8?q?feat:=20remoteCharInsertOperation=EC=97=90?= =?UTF-8?q?=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=86=8D=EC=84=B1=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/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 5/8] =?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 6/8] =?UTF-8?q?feat:=20getTextOffset=20=EC=9C=A0=ED=8B=B8?= =?UTF-8?q?=EB=A6=AC=ED=8B=B0=20=ED=95=A8=EC=88=98=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=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 7/8] =?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 8/8] =?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)}