Skip to content

Commit

Permalink
Merge pull request #192 from boostcampwm-2024/Feature/#053_블럭내_복사_붙여넣…
Browse files Browse the repository at this point in the history
…기_구현

Feature/#53 블럭내 복사 붙여넣기 구현
  • Loading branch information
github-actions[bot] authored Nov 26, 2024
2 parents 911fbdf + 19e9844 commit 1169885
Show file tree
Hide file tree
Showing 6 changed files with 209 additions and 123 deletions.
8 changes: 8 additions & 0 deletions @noctaCrdt/Crdt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,14 @@ export class BlockCRDT extends CRDT<Char> {
});
}

if (operation.color) {
newNode.color = operation.color;
}

if (operation.backgroundColor) {
newNode.backgroundColor = operation.backgroundColor;
}

this.LinkedList.insertById(newNode);

if (this.clock <= newNode.id.clock) {
Expand Down
2 changes: 2 additions & 0 deletions @noctaCrdt/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export interface RemoteCharInsertOperation {
blockId: BlockId;
pageId: string;
style?: string[];
color?: TextColorType;
backgroundColor?: BackgroundColorType;
}

export interface RemoteBlockDeleteOperation {
Expand Down
158 changes: 154 additions & 4 deletions client/src/features/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -22,17 +24,26 @@ 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;
pageId: string;
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 {
Expand Down Expand Up @@ -73,7 +84,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr
sendCharInsertOperation,
});

const { handleKeyDown } = useMarkdownGrammer({
const { handleKeyDown: onKeyDown } = useMarkdownGrammer({
editorCRDT: editorCRDT.current,
editorState,
setEditorState,
Expand Down Expand Up @@ -171,6 +182,143 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr
[sendCharInsertOperation, sendCharDeleteOperation],
);

const handleKeyDown = (
e: React.KeyboardEvent<HTMLDivElement>,
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<HTMLDivElement>,
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<HTMLDivElement>, 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<HTMLDivElement>, block: CRDTBlock) => {
const event = e.nativeEvent as CompositionEvent;
const characters = [...event.data];
Expand Down Expand Up @@ -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}
Expand Down
51 changes: 22 additions & 29 deletions client/src/features/editor/components/block/Block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -27,7 +27,17 @@ interface BlockProps {
isActive: boolean;
onInput: (e: React.FormEvent<HTMLDivElement>, block: CRDTBlock) => void;
onCompositionEnd: (e: React.CompositionEvent<HTMLDivElement>, block: CRDTBlock) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void;
onKeyDown: (
e: React.KeyboardEvent<HTMLDivElement>,
blockRef: HTMLDivElement | null,
block: CRDTBlock,
) => void;
onCopy: (
e: React.ClipboardEvent<HTMLDivElement>,
blockRef: HTMLDivElement | null,
block: CRDTBlock,
) => void;
onPaste: (e: React.ClipboardEvent<HTMLDivElement>, block: CRDTBlock) => void;
onClick: (blockId: BlockId, e: React.MouseEvent<HTMLDivElement>) => void;
onAnimationSelect: (blockId: BlockId, animation: AnimationType) => void;
onTypeSelect: (blockId: BlockId, type: ElementType) => void;
Expand All @@ -53,6 +63,8 @@ export const Block: React.FC<BlockProps> = memo(
onInput,
onCompositionEnd,
onKeyDown,
onCopy,
onPaste,
onClick,
onAnimationSelect,
onTypeSelect,
Expand Down Expand Up @@ -137,26 +149,8 @@ export const Block: React.FC<BlockProps> = 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);
Expand All @@ -169,11 +163,10 @@ export const Block: React.FC<BlockProps> = 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();
Expand All @@ -182,10 +175,9 @@ export const Block: React.FC<BlockProps> = 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();
Expand All @@ -194,10 +186,9 @@ export const Block: React.FC<BlockProps> = 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();
Expand Down Expand Up @@ -241,9 +232,11 @@ export const Block: React.FC<BlockProps> = memo(
<IconBlock type={block.type} index={1} />
<div
ref={blockRef}
onKeyDown={onKeyDown}
onKeyDown={(e) => 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
Expand Down
Loading

0 comments on commit 1169885

Please sign in to comment.