Skip to content

Commit

Permalink
Merge pull request #249 from boostcampwm-2024/Hotfix/#248_같은블록_동시입력시_…
Browse files Browse the repository at this point in the history
…캐럿이_튀는_문제_수정

Hotfix/#248 같은블록 동시입력시 캐럿이 튀는 문제 수정
  • Loading branch information
github-actions[bot] authored Dec 1, 2024
2 parents e73b2ca + baf463e commit 91583ef
Show file tree
Hide file tree
Showing 10 changed files with 185 additions and 53 deletions.
94 changes: 75 additions & 19 deletions client/src/features/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Block as CRDTBlock } from "@noctaCrdt/Node";
import { serializedEditorDataProps } from "node_modules/@noctaCrdt/Interfaces.ts";
import { useRef, useState, useCallback, useEffect, useMemo } 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,
Expand Down Expand Up @@ -67,6 +67,9 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData
}, [serializedEditorData, clientId]);

const editorCRDT = useRef<EditorCRDT>(editorCRDTInstance);
const isLocalChange = useRef(false);
const isSameLocalChange = useRef(false);
const composingCaret = useRef<number | null>(null);

// editorState도 editorCRDT가 변경될 때마다 업데이트
const [editorState, setEditorState] = useState<EditorStateProps>({
Expand All @@ -84,13 +87,14 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData
handleRemoteCharUpdate,
handleRemoteCursor,
addNewBlock,
} = useEditorOperation({ editorCRDT, pageId, setEditorState });
} = useEditorOperation({ editorCRDT, pageId, setEditorState, isSameLocalChange });

const { sensors, handleDragEnd, handleDragStart } = useBlockDragAndDrop({
editorCRDT: editorCRDT.current,
editorState,
setEditorState,
pageId,
isLocalChange,
});

const { handleTypeSelect, handleAnimationSelect, handleCopySelect, handleDeleteSelect } =
Expand Down Expand Up @@ -123,20 +127,23 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData
pageId,
onKeyDown,
handleHrInput,
isLocalChange,
});

const { onTextStyleUpdate, onTextColorUpdate, onTextBackgroundColorUpdate } = useTextOptionSelect(
{
editorCRDT: editorCRDT.current,
setEditorState,
pageId,
isLocalChange,
},
);

const { handleCopy, handlePaste } = useCopyAndPaste({
editorCRDT: editorCRDT.current,
setEditorState,
pageId,
isLocalChange,
});

const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
Expand All @@ -149,34 +156,76 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData
const newTitle = e.target.value;
if (newTitle === "") {
setDisplayTitle(""); // 입력이 비어있으면 로컬상태는 빈 문자열로
onTitleChange("새로운 페이지", true); // 서버에는 "새로운 페이지"로 저장
} else {
onTitleChange(newTitle, true);
}
};

const handleCompositionStart = (e: React.CompositionEvent<HTMLDivElement>, block: CRDTBlock) => {
const currentText = e.data;
composingCaret.current = getAbsoluteCaretPosition(e.currentTarget);
block.crdt.localInsert(composingCaret.current, currentText, block.id, pageId);
};

const handleCompositionUpdate = (e: React.CompositionEvent<HTMLDivElement>, block: CRDTBlock) => {
const currentText = e.data;
if (composingCaret.current === null) return;
const currentCaret = composingCaret.current;
const currentCharNode = block.crdt.LinkedList.findByIndex(currentCaret);
if (!currentCharNode) return;
currentCharNode.value = currentText;
};

const handleCompositionEnd = useCallback(
(e: React.CompositionEvent<HTMLDivElement>, block: CRDTBlock) => {
if (!editorCRDT) return;
const event = e.nativeEvent as CompositionEvent;
const characters = [...event.data];
const selection = window.getSelection();
const caretPosition = selection?.focusOffset || 0;
const startPosition = caretPosition - characters.length;
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;

characters.forEach((char, index) => {
const insertPosition = startPosition + index;
const charNode = block.crdt.localInsert(insertPosition, char, block.id, pageId);
if (!composingCaret.current) return;
const currentCaret = composingCaret.current;
const currentCharNode = block.crdt.LinkedList.findByIndex(currentCaret);
if (!currentCharNode) return;

if (isMac) {
const [character, space] = event.data;
if (!character || !composingCaret.current) return;
if (!currentCharNode) return;
currentCharNode.value = character;
sendCharInsertOperation({
type: "charInsert",
node: charNode.node,
node: currentCharNode,
blockId: block.id,
pageId,
});
});
if (space) {
const spaceNode = block.crdt.localInsert(currentCaret + 1, space, block.id, pageId);
sendCharInsertOperation({
type: "charInsert",
node: spaceNode.node,
blockId: block.id,
pageId,
});
}
block.crdt.currentCaret = currentCaret + 2;
} else {
// Windows의 경우
const character = event.data;
if (!character) return;

block.crdt.currentCaret = caretPosition;
currentCharNode.value = character;
sendCharInsertOperation({
type: "charInsert",
node: currentCharNode,
blockId: block.id,
pageId,
});
sendCharInsertOperation(block.crdt.localInsert(currentCaret + 1, "", block.id, pageId));

block.crdt.currentCaret = currentCaret;
}

composingCaret.current = null;
},
[editorCRDT, pageId, sendCharInsertOperation],
);
Expand All @@ -190,12 +239,17 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData
if (activeElement?.tagName.toLowerCase() === "input") {
return; // input에 포커스가 있으면 캐럿 위치 변경하지 않음
}
setCaretPosition({
blockId: editorCRDT.current.currentBlock.id,
linkedList: editorCRDT.current.LinkedList,
position: editorCRDT.current.currentBlock?.crdt.currentCaret,
pageId,
});
if (isLocalChange.current || isSameLocalChange.current) {
setCaretPosition({
blockId: editorCRDT.current.currentBlock.id,
linkedList: editorCRDT.current.LinkedList,
position: editorCRDT.current.currentBlock?.crdt.currentCaret,
pageId,
});
isLocalChange.current = false;
isSameLocalChange.current = false;
return;
}
// 서윤님 피드백 반영
}, [editorCRDT.current.currentBlock?.id.serialize()]);

Expand Down Expand Up @@ -300,6 +354,8 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData
block={block}
isActive={block.id === editorCRDT.current.currentBlock?.id}
onInput={handleBlockInput}
onCompositionStart={handleCompositionStart}
onCompositionUpdate={handleCompositionUpdate}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
onCopy={handleCopy}
Expand Down
6 changes: 6 additions & 0 deletions client/src/features/editor/components/block/Block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ interface BlockProps {
dragBlockList: string[];
isActive: boolean;
onInput: (e: React.FormEvent<HTMLDivElement>, block: CRDTBlock) => void;
onCompositionStart: (e: React.CompositionEvent<HTMLDivElement>, block: CRDTBlock) => void;
onCompositionUpdate: (e: React.CompositionEvent<HTMLDivElement>, block: CRDTBlock) => void;
onCompositionEnd: (e: React.CompositionEvent<HTMLDivElement>, block: CRDTBlock) => void;
onKeyDown: (
e: React.KeyboardEvent<HTMLDivElement>,
Expand Down Expand Up @@ -72,6 +74,8 @@ export const Block: React.FC<BlockProps> = memo(
dragBlockList,
isActive,
onInput,
onCompositionStart,
onCompositionUpdate,
onCompositionEnd,
onKeyDown,
onCopy,
Expand Down Expand Up @@ -278,6 +282,8 @@ export const Block: React.FC<BlockProps> = memo(
onCopy={(e) => onCopy(e, blockRef.current, block)}
onPaste={(e) => onPaste(e, blockRef.current, block)}
onMouseUp={handleMouseUp}
onCompositionStart={(e) => onCompositionStart(e, block)}
onCompositionUpdate={(e) => onCompositionUpdate(e, block)}
onCompositionEnd={(e) => onCompositionEnd(e, block)}
contentEditable={block.type !== "hr"}
spellCheck={false}
Expand Down
8 changes: 6 additions & 2 deletions client/src/features/editor/hooks/useBlockDragAndDrop.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
import { DragEndEvent, DragStartEvent, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { EditorCRDT } from "@noctaCrdt/Crdt";
import { Block } from "@noctaCrdt/Node";
import { useSocketStore } from "@src/stores/useSocketStore.ts";
import { EditorStateProps } from "../Editor";
import {
RemoteBlockReorderOperation,
RemoteBlockUpdateOperation,
} from "node_modules/@noctaCrdt/Interfaces";
import { useSocketStore } from "@src/stores/useSocketStore.ts";
import { EditorStateProps } from "../Editor";

interface UseBlockDragAndDropProps {
editorCRDT: EditorCRDT;
editorState: EditorStateProps;
setEditorState: React.Dispatch<React.SetStateAction<EditorStateProps>>;
pageId: string;
isLocalChange: React.MutableRefObject<boolean>;
}

export const useBlockDragAndDrop = ({
editorCRDT,
editorState,
setEditorState,
pageId,
isLocalChange,
}: UseBlockDragAndDropProps) => {
const sensors = useSensors(
useSensor(PointerSensor, {
Expand Down Expand Up @@ -123,6 +125,7 @@ export const useBlockDragAndDrop = ({
if (disableDrag) return;

try {
isLocalChange.current = true;
const nodes = editorState.linkedList.spread();

// ID 문자열에서 client와 clock 추출
Expand Down Expand Up @@ -203,6 +206,7 @@ export const useBlockDragAndDrop = ({
);

if (parentIndex === -1) return [];
isLocalChange.current = true;

const childBlockIds = [];

Expand Down
8 changes: 7 additions & 1 deletion client/src/features/editor/hooks/useBlockOperation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface UseBlockOperationProps {
setEditorState: React.Dispatch<React.SetStateAction<EditorStateProps>>;
onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void;
handleHrInput: (block: Block, content: string) => boolean;
isLocalChange: React.MutableRefObject<boolean>;
}

export const useBlockOperation = ({
Expand All @@ -22,12 +23,14 @@ export const useBlockOperation = ({
setEditorState,
onKeyDown,
handleHrInput,
isLocalChange,
}: UseBlockOperationProps) => {
const { sendCharInsertOperation, sendCharDeleteOperation } = useSocketStore();

const handleBlockClick = useCallback(
(blockId: BlockId, e: React.MouseEvent<HTMLDivElement>) => {
if (editorCRDT) {
isLocalChange.current = true;
const selection = window.getSelection();
if (!selection) return;

Expand All @@ -52,6 +55,7 @@ export const useBlockOperation = ({
if ((e.nativeEvent as InputEvent).isComposing) {
return;
}
isLocalChange.current = true;

let operationNode;
const element = e.currentTarget;
Expand Down Expand Up @@ -79,6 +83,7 @@ export const useBlockOperation = ({
currentContent.length - 1,
);
}
console.log("prevChar", prevChar);
const addedChar = newContent[newContent.length - 1];
charNode = block.crdt.localInsert(
currentContent.length,
Expand Down Expand Up @@ -236,13 +241,14 @@ export const useBlockOperation = ({

// 선택된 텍스트가 없으면 기본 키 핸들러 실행
if (selection.isCollapsed) {
isLocalChange.current = true;
onKeyDown(e);
return;
}

const range = selection.getRangeAt(0);
if (!blockRef.contains(range.commonAncestorContainer)) return;

isLocalChange.current = true;
const startOffset = getTextOffset(blockRef, range.startContainer, range.startOffset);
const endOffset = getTextOffset(blockRef, range.endContainer, range.endOffset);

Expand Down
16 changes: 13 additions & 3 deletions client/src/features/editor/hooks/useCopyAndPaste.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,15 @@ interface UseCopyAndPasteProps {
editorCRDT: EditorCRDT;
pageId: string;
setEditorState: React.Dispatch<React.SetStateAction<EditorStateProps>>;
isLocalChange: React.MutableRefObject<boolean>;
}

export const useCopyAndPaste = ({ editorCRDT, pageId, setEditorState }: UseCopyAndPasteProps) => {
export const useCopyAndPaste = ({
editorCRDT,
pageId,
setEditorState,
isLocalChange,
}: UseCopyAndPasteProps) => {
const { sendCharInsertOperation, sendCharDeleteOperation } = useSocketStore();

const handleCopy = useCallback(
Expand Down Expand Up @@ -67,10 +73,14 @@ export const useCopyAndPaste = ({ editorCRDT, pageId, setEditorState }: UseCopyA
if (!blockRef) return;

const selection = window.getSelection();

isLocalChange.current = true;
if (selection && !selection.isCollapsed) {
const range = selection.getRangeAt(0);
if (!blockRef.contains(range.commonAncestorContainer)) return;
if (!blockRef.contains(range.commonAncestorContainer)) {
// ?????
isLocalChange.current = false;
return;
}

const startOffset = getTextOffset(blockRef, range.startContainer, range.startOffset);
const endOffset = getTextOffset(blockRef, range.endContainer, range.endOffset);
Expand Down
Loading

0 comments on commit 91583ef

Please sign in to comment.