diff --git a/client/src/features/editor/components/block/Block.style.ts b/client/src/features/editor/components/block/Block.style.ts index 8b884a28..943ed376 100644 --- a/client/src/features/editor/components/block/Block.style.ts +++ b/client/src/features/editor/components/block/Block.style.ts @@ -64,6 +64,7 @@ export const textContainerStyle = cva({ base: { ...baseTextStyle, position: "relative", + wordBreak: "break-word", overflowWrap: "break-word", whiteSpace: "pre-wrap", "&:empty::before": { diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index d3e95fe8..b7d33c45 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -38,7 +38,11 @@ interface BlockProps { blockRef: HTMLDivElement | null, block: CRDTBlock, ) => void; - onPaste: (e: React.ClipboardEvent, block: CRDTBlock) => void; + onPaste: ( + e: React.ClipboardEvent, + blockRef: HTMLDivElement | null, + block: CRDTBlock, + ) => void; onClick: (blockId: BlockId, e: React.MouseEvent) => void; onAnimationSelect: (blockId: BlockId, animation: AnimationType) => void; onTypeSelect: (blockId: BlockId, type: ElementType) => void; @@ -253,7 +257,7 @@ export const Block: React.FC = memo( onInput={handleInput} onClick={(e) => onClick(block.id, e)} onCopy={(e) => onCopy(e, blockRef.current, block)} - onPaste={(e) => onPaste(e, block)} + onPaste={(e) => onPaste(e, blockRef.current, block)} onMouseUp={handleMouseUp} onCompositionEnd={(e) => onCompositionEnd(e, block)} contentEditable={block.type !== "hr"} diff --git a/client/src/features/editor/hooks/useBlockOperation.ts b/client/src/features/editor/hooks/useBlockOperation.ts index 18f08547..4dfa1769 100644 --- a/client/src/features/editor/hooks/useBlockOperation.ts +++ b/client/src/features/editor/hooks/useBlockOperation.ts @@ -127,39 +127,121 @@ export const useBlockOperation = ({ [sendCharInsertOperation, sendCharDeleteOperation, editorCRDT, pageId], ); + const deleteSelectedText = useCallback( + (block: Block, startOffset: number, endOffset: number) => { + for (let i = endOffset - 1; i >= startOffset; i--) { + const operationNode = block.crdt.localDelete(i, block.id, pageId); + sendCharDeleteOperation(operationNode); + } + block.crdt.currentCaret = startOffset; + }, + [pageId, sendCharDeleteOperation], + ); + + const handleKeyWithSelection = useCallback( + ( + e: React.KeyboardEvent, + block: Block, + startOffset: number, + endOffset: number, + ) => { + switch (e.key) { + case "Backspace": + case "Delete": { + e.preventDefault(); + deleteSelectedText(block, startOffset, endOffset); + setEditorState({ + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, + }); + break; + } + // 복사, 잘라내기, 실행취소 등 조합 키는 기본 동작 허용 + case "c": + case "v": + case "x": + case "z": + case "y": { + if (e.metaKey || e.ctrlKey) { + // 기본 브라우저 동작 허용 + return; + } + deleteSelectedText(block, startOffset, endOffset); + onKeyDown(e); + break; + } + // 탐색 및 선택 관련 키 + case "Tab": + case "ArrowLeft": + case "ArrowRight": + case "ArrowUp": + case "ArrowDown": + case "Home": + case "End": + case "PageUp": + case "PageDown": { + e.preventDefault(); + onKeyDown(e); + break; + } + // 기능 키들은 기본 동작 허용 + case "F1": + case "F2": + case "F3": + case "F4": + case "F5": + case "F6": + case "F7": + case "F8": + case "F9": + case "F10": + case "F11": + case "F12": { + return; + } + case "Enter": { + deleteSelectedText(block, startOffset, endOffset); + onKeyDown(e); + break; + } + case "Escape": { + // 선택 해제만 하고 다른 동작은 하지 않음 + window.getSelection()?.removeAllRanges(); + return; + } + default: { + // 일반 입력 키의 경우 + if (e.metaKey || e.ctrlKey || e.altKey) { + // 다른 단축키들 허용 + return; + } + deleteSelectedText(block, startOffset, endOffset); + onKeyDown(e); + } + } + }, + [deleteSelectedText, editorCRDT, onKeyDown], + ); + const handleKeyDown = useCallback( (e: React.KeyboardEvent, blockRef: HTMLDivElement | null, block: Block) => { if (!blockRef || !block) return; const selection = window.getSelection(); - if (!selection || selection.isCollapsed || !blockRef) { - // 선택된 텍스트가 없으면 기존 onKeyDown 로직 실행 + if (!selection) return; + + // 선택된 텍스트가 없으면 기본 키 핸들러 실행 + if (selection.isCollapsed) { onKeyDown(e); return; } - if (e.key === "Backspace") { - e.preventDefault(); + const range = selection.getRangeAt(0); + if (!blockRef.contains(range.commonAncestorContainer)) 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 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.clock, - linkedList: editorCRDT.LinkedList, - }); - } else { - onKeyDown(e); - } + handleKeyWithSelection(e, block, startOffset, endOffset); }, [editorCRDT.LinkedList, sendCharDeleteOperation, pageId, onKeyDown], ); diff --git a/client/src/features/editor/hooks/useBlockOption.ts b/client/src/features/editor/hooks/useBlockOption.ts index f1c7ef61..611e0d99 100644 --- a/client/src/features/editor/hooks/useBlockOption.ts +++ b/client/src/features/editor/hooks/useBlockOption.ts @@ -7,10 +7,10 @@ import { RemoteBlockUpdateOperation, RemoteCharInsertOperation, } from "@noctaCrdt/Interfaces"; +import { Block } from "@noctaCrdt/Node"; import { BlockId } from "@noctaCrdt/NodeId"; import { BlockLinkedList } from "node_modules/@noctaCrdt/LinkedList"; import { EditorStateProps } from "../Editor"; -import { Block } from "@noctaCrdt/Node"; interface useBlockOptionSelectProps { editorCRDT: EditorCRDT; diff --git a/client/src/features/editor/hooks/useCopyAndPaste.ts b/client/src/features/editor/hooks/useCopyAndPaste.ts index f7b5592f..a2a11520 100644 --- a/client/src/features/editor/hooks/useCopyAndPaste.ts +++ b/client/src/features/editor/hooks/useCopyAndPaste.ts @@ -20,7 +20,7 @@ interface UseCopyAndPasteProps { } export const useCopyAndPaste = ({ editorCRDT, pageId, setEditorState }: UseCopyAndPasteProps) => { - const { sendCharInsertOperation } = useSocketStore(); + const { sendCharInsertOperation, sendCharDeleteOperation } = useSocketStore(); const handleCopy = useCallback( (e: React.ClipboardEvent, blockRef: HTMLDivElement | null, block: Block) => { @@ -61,64 +61,84 @@ export const useCopyAndPaste = ({ editorCRDT, pageId, setEditorState }: UseCopyA [], ); - const handlePaste = useCallback((e: React.ClipboardEvent, block: Block) => { - 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, + const handlePaste = useCallback( + (e: React.ClipboardEvent, blockRef: HTMLDivElement | null, block: Block) => { + e.preventDefault(); + if (!blockRef) return; + + const selection = window.getSelection(); + + if (selection && !selection.isCollapsed) { + 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); + } + } + + 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.currentBlock!.crdt.currentCaret = caretPosition + metadata.length; - } else { - const text = e.clipboardData.getData("text/plain"); + editorCRDT.currentBlock!.crdt.currentCaret = caretPosition + metadata.length; + } else { + const text = e.clipboardData.getData("text/plain"); - if (!block || text.length === 0) return; + if (!block || text.length === 0) return; - const caretPosition = block.crdt.currentCaret; + 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, + // 텍스트를 한 글자씩 순차적으로 삽입 + 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.currentBlock!.crdt.currentCaret = caretPosition + text.length; - } + // 캐럿 위치 업데이트 + editorCRDT.currentBlock!.crdt.currentCaret = caretPosition + text.length; + } - setEditorState({ - clock: editorCRDT.clock, - linkedList: editorCRDT.LinkedList, - }); - }, []); + setEditorState({ + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, + }); + }, + [], + ); return { handleCopy, handlePaste }; }; diff --git a/client/src/features/editor/hooks/useMarkdownGrammer.ts b/client/src/features/editor/hooks/useMarkdownGrammer.ts index 8a308054..96b64bfa 100644 --- a/client/src/features/editor/hooks/useMarkdownGrammer.ts +++ b/client/src/features/editor/hooks/useMarkdownGrammer.ts @@ -384,6 +384,71 @@ export const useMarkdownGrammer = ({ break; } + case "Delete": { + const currentContent = currentBlock.crdt.read(); + if (!currentBlock.next || currentContent) return; + const nextBlock = editorCRDT.LinkedList.getNode(currentBlock.next); + if (!nextBlock) return; + sendCharDeleteOperation( + currentBlock.crdt.localDelete(currentIndex + 1, currentBlock.id, pageId), + ); + updateEditorState(); + break; + } + + case "Home": + case "End": { + currentBlock.crdt.currentCaret = e.key === "Home" ? 0 : currentBlock.crdt.read().length; + setCaretPosition({ + blockId: currentBlock.id, + linkedList: editorCRDT.LinkedList, + position: currentBlock.crdt.currentCaret, + pageId, + }); + break; + } + + case "PageUp": { + e.preventDefault(); + const currentCaretPosition = currentBlock.crdt.currentCaret; + const headBlock = editorCRDT.LinkedList.getNode(editorCRDT.LinkedList.head); + if (!headBlock) return; + headBlock.crdt.currentCaret = Math.min( + currentCaretPosition, + headBlock.crdt.read().length, + ); + editorCRDT.currentBlock = headBlock; + setCaretPosition({ + blockId: headBlock.id, + linkedList: editorCRDT.LinkedList, + position: currentCaretPosition, + pageId, + }); + break; + } + + case "PageDown": { + e.preventDefault(); + if (!currentBlock.next) return; + const currentCaretPosition = currentBlock.crdt.currentCaret; + let lastBlock = currentBlock; + while (lastBlock.next && editorCRDT.LinkedList.getNode(lastBlock.next)) { + lastBlock = editorCRDT.LinkedList.getNode(lastBlock.next)!; + } + lastBlock.crdt.currentCaret = Math.min( + currentCaretPosition, + lastBlock.crdt.read().length, + ); + editorCRDT.currentBlock = lastBlock; + setCaretPosition({ + blockId: lastBlock.id, + linkedList: editorCRDT.LinkedList, + position: currentCaretPosition, + pageId, + }); + break; + } + case "ArrowUp": case "ArrowDown": { const hasPrevBlock = currentIndex > 0;