diff --git a/@noctaCrdt/src/LinkedList.ts b/@noctaCrdt/src/LinkedList.ts index d5323af..aa22a58 100644 --- a/@noctaCrdt/src/LinkedList.ts +++ b/@noctaCrdt/src/LinkedList.ts @@ -278,12 +278,18 @@ export class BlockLinkedList extends LinkedList { let currentIndex = 1; while (currentNode) { - if (currentNode.deleted) { - currentNode = currentNode.next ? this.getNode(currentNode.next) : null; - continue; - } if (currentNode.type === "ol") { - const prevNode = currentNode.prev ? this.getNode(currentNode.prev) : null; + let prevNode = currentNode.prev ? this.getNode(currentNode.prev) : null; + + // tombstone 노드를 건너뛰는 로직 + while (prevNode && prevNode.deleted) { + // 이전 노드의 prev를 다시 추적 + if (prevNode.prev) { + prevNode = this.getNode(prevNode.prev); + } else { + prevNode = null; + } + } if (!prevNode || prevNode.type !== "ol") { // 이전 노드가 없거나 ol이 아닌 경우 1부터 시작 diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index 9a69104..800b980 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -4,7 +4,7 @@ import { EditorCRDT } from "@noctaCrdt/Crdt"; import { BlockLinkedList } from "@noctaCrdt/LinkedList"; import { Block as CRDTBlock } from "@noctaCrdt/Node"; import { serializedEditorDataProps } from "@noctaCrdt/types/Interfaces"; -import { useRef, useState, useCallback, useEffect, useMemo } from "react"; +import { useRef, useState, useCallback, useEffect, useMemo, memo } from "react"; import { useSocketStore } from "@src/stores/useSocketStore.ts"; import { setCaretPosition, getAbsoluteCaretPosition } from "@src/utils/caretUtils.ts"; import { @@ -29,388 +29,404 @@ export interface EditorStateProps { interface EditorProps { testKey: string; + isResizing: boolean; onTitleChange: (title: string, syncWithServer: boolean) => void; pageId: string; serializedEditorData: serializedEditorDataProps; pageTitle: string; } -export const Editor = ({ - testKey, - onTitleChange, - pageId, - pageTitle, - serializedEditorData, -}: EditorProps) => { - const { - sendCharInsertOperation, - sendCharDeleteOperation, - subscribeToRemoteOperations, - sendBlockInsertOperation, - sendBlockDeleteOperation, - sendBlockUpdateOperation, - sendBlockCheckboxOperation, - } = useSocketStore(); - const { clientId } = useSocketStore(); - const [displayTitle, setDisplayTitle] = useState(pageTitle); - const [dragBlockList, setDragBlockList] = useState([]); - - useEffect(() => { - if (pageTitle === "새로운 페이지" || pageTitle === "") { - setDisplayTitle(""); - } else { - setDisplayTitle(pageTitle); - } - }, [pageTitle]); - - const editorCRDTInstance = useMemo(() => { - let newEditorCRDT; - if (serializedEditorData) { - newEditorCRDT = new EditorCRDT(serializedEditorData.client); - newEditorCRDT.deserialize(serializedEditorData); - } else { - newEditorCRDT = new EditorCRDT(clientId ? clientId : 0); - } - return newEditorCRDT; - }, [serializedEditorData, clientId]); - - const editorCRDT = useRef(editorCRDTInstance); - const isLocalChange = useRef(false); - const isSameLocalChange = useRef(false); - const composingCaret = useRef(null); - - // editorState도 editorCRDT가 변경될 때마다 업데이트 - const [editorState, setEditorState] = useState({ - clock: editorCRDT.current.clock, - linkedList: editorCRDT.current.LinkedList, - }); - - const { - handleRemoteBlockInsert, - handleRemoteBlockDelete, - handleRemoteCharInsert, - handleRemoteCharDelete, - handleRemoteBlockUpdate, - handleRemoteBlockReorder, - handleRemoteCharUpdate, - handleRemoteCursor, - handleRemoteBlockCheckbox, - addNewBlock, - } = useEditorOperation({ editorCRDT, pageId, setEditorState, isSameLocalChange }); - - const { sensors, handleDragEnd, handleDragStart } = useBlockDragAndDrop({ - editorCRDT: editorCRDT.current, - editorState, - setEditorState, - pageId, - isLocalChange, - }); - - const { handleTypeSelect, handleAnimationSelect, handleCopySelect, handleDeleteSelect } = - useBlockOptionSelect({ +export const Editor = memo( + ({ testKey, onTitleChange, pageId, pageTitle, serializedEditorData }: EditorProps) => { + const { + sendCharInsertOperation, + sendCharDeleteOperation, + subscribeToRemoteOperations, + sendBlockInsertOperation, + sendBlockDeleteOperation, + sendBlockUpdateOperation, + sendBlockCheckboxOperation, + } = useSocketStore(); + const { clientId } = useSocketStore(); + const [displayTitle, setDisplayTitle] = useState(pageTitle); + const [dragBlockList, setDragBlockList] = useState([]); + console.log(serializedEditorData); + + useEffect(() => { + if (pageTitle === "새로운 페이지" || pageTitle === "") { + setDisplayTitle(""); + } else { + setDisplayTitle(pageTitle); + } + }, [pageTitle]); + + const editorCRDTInstance = useMemo(() => { + let newEditorCRDT; + if (serializedEditorData) { + newEditorCRDT = new EditorCRDT(serializedEditorData.client); + newEditorCRDT.deserialize(serializedEditorData); + } else { + newEditorCRDT = new EditorCRDT(clientId ? clientId : 0); + } + return newEditorCRDT; + }, [serializedEditorData, clientId]); + + const editorCRDT = useRef(editorCRDTInstance); + const isLocalChange = useRef(false); + const isSameLocalChange = useRef(false); + const composingCaret = useRef(null); + + // editorState도 editorCRDT가 변경될 때마다 업데이트 + const [editorState, setEditorState] = useState({ + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, + }); + + const { + handleRemoteBlockInsert, + handleRemoteBlockDelete, + handleRemoteCharInsert, + handleRemoteCharDelete, + handleRemoteBlockUpdate, + handleRemoteBlockReorder, + handleRemoteCharUpdate, + handleRemoteCursor, + handleRemoteBlockCheckbox, + addNewBlock, + } = useEditorOperation({ editorCRDT, pageId, setEditorState, isSameLocalChange }); + + const { sensors, handleDragEnd, handleDragStart } = useBlockDragAndDrop({ editorCRDT: editorCRDT.current, editorState, setEditorState, pageId, - sendBlockUpdateOperation, - sendBlockDeleteOperation, - sendBlockInsertOperation, - sendCharInsertOperation, + isLocalChange, }); - const { handleKeyDown: onKeyDown, handleInput: handleHrInput } = useMarkdownGrammer({ - editorCRDT: editorCRDT.current, - editorState, - setEditorState, - pageId, - sendBlockInsertOperation, - sendBlockDeleteOperation, - sendBlockUpdateOperation, - sendCharDeleteOperation, - sendCharInsertOperation, - }); - - const { handleBlockClick, handleBlockInput, handleKeyDown, handleCheckboxToggle } = - useBlockOperation({ + const { handleTypeSelect, handleAnimationSelect, handleCopySelect, handleDeleteSelect } = + useBlockOptionSelect({ + editorCRDT: editorCRDT.current, + editorState, + setEditorState, + pageId, + sendBlockUpdateOperation, + sendBlockDeleteOperation, + sendBlockInsertOperation, + sendCharInsertOperation, + }); + + const { handleKeyDown: onKeyDown, handleInput: handleHrInput } = useMarkdownGrammer({ editorCRDT: editorCRDT.current, + editorState, setEditorState, pageId, - onKeyDown, - handleHrInput, - isLocalChange, - sendBlockCheckboxOperation, + sendBlockInsertOperation, + sendBlockDeleteOperation, + sendBlockUpdateOperation, + sendCharDeleteOperation, + sendCharInsertOperation, }); - const { onTextStyleUpdate, onTextColorUpdate, onTextBackgroundColorUpdate } = useTextOptionSelect( - { + const { handleBlockClick, handleBlockInput, handleKeyDown, handleCheckboxToggle } = + useBlockOperation({ + editorCRDT: editorCRDT.current, + setEditorState, + pageId, + onKeyDown, + handleHrInput, + isLocalChange, + sendBlockCheckboxOperation, + }); + + const { onTextStyleUpdate, onTextColorUpdate, onTextBackgroundColorUpdate } = + useTextOptionSelect({ + editorCRDT: editorCRDT.current, + setEditorState, + pageId, + isLocalChange, + }); + + const { handleCopy, handlePaste } = useCopyAndPaste({ editorCRDT: editorCRDT.current, setEditorState, pageId, isLocalChange, - }, - ); - - const { handleCopy, handlePaste } = useCopyAndPaste({ - editorCRDT: editorCRDT.current, - setEditorState, - pageId, - isLocalChange, - }); - - const handleTitleChange = (e: React.ChangeEvent) => { - const newTitle = e.target.value; - setDisplayTitle(newTitle); // 로컬 상태 업데이트 - onTitleChange(newTitle, false); // 낙관적 업데이트 - }; - - const handleBlur = (e: React.ChangeEvent) => { - const newTitle = e.target.value; - if (newTitle === "") { - setDisplayTitle(""); // 입력이 비어있으면 로컬상태는 빈 문자열로 - } else { - onTitleChange(newTitle, true); - } - }; - - const handleCompositionStart = (e: React.CompositionEvent, 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, 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, block: CRDTBlock) => { - if (!editorCRDT) return; - const event = e.nativeEvent as CompositionEvent; - const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; + }); + + const handleTitleChange = (e: React.ChangeEvent) => { + const newTitle = e.target.value; + setDisplayTitle(newTitle); // 로컬 상태 업데이트 + onTitleChange(newTitle, false); // 낙관적 업데이트 + }; + const handleBlur = (e: React.ChangeEvent) => { + const newTitle = e.target.value; + if (newTitle === "") { + setDisplayTitle(""); // 입력이 비어있으면 로컬상태는 빈 문자열로 + } else { + onTitleChange(newTitle, true); + } + }; + + const handleCompositionStart = ( + e: React.CompositionEvent, + 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, + 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, block: CRDTBlock) => { + if (!editorCRDT) return; + const event = e.nativeEvent as CompositionEvent; + const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; - if (isMac) { - const [character, space] = event.data; - if (!character || composingCaret.current === null) return; + if (composingCaret.current === null) return; + const currentCaret = composingCaret.current; + const currentCharNode = block.crdt.LinkedList.findByIndex(currentCaret); if (!currentCharNode) return; - currentCharNode.value = character; - sendCharInsertOperation({ - type: "charInsert", - 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; - - // 문자열을 개별 문자로 분리 - const characters = Array.from(character); - let currentPosition = currentCaret; - - // 각 문자에 대해 처리 - characters.forEach((char, index) => { - // 현재 위치의 노드 찾기 - const charNode = block.crdt.LinkedList.findByIndex(currentPosition); - if (!charNode) return; - - // 노드 값 설정 및 operation 전송 - charNode.value = char; + + if (isMac) { + const [character, space] = event.data; + if (!character || composingCaret.current === null) return; + if (!currentCharNode) return; + currentCharNode.value = character; sendCharInsertOperation({ type: "charInsert", - node: charNode, + node: currentCharNode, blockId: block.id, pageId, }); - - // 다음 문자를 위한 새 노드 생성 (마지막 문자가 아닌 경우에만) - if (index < characters.length - 1) { - block.crdt.localInsert(currentPosition + 1, "", 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; + + // 문자열을 개별 문자로 분리 + const characters = Array.from(character); + let currentPosition = currentCaret; + + // 각 문자에 대해 처리 + characters.forEach((char, index) => { + // 현재 위치의 노드 찾기 + const charNode = block.crdt.LinkedList.findByIndex(currentPosition); + if (!charNode) return; + + // 노드 값 설정 및 operation 전송 + charNode.value = char; + sendCharInsertOperation({ + type: "charInsert", + node: charNode, + blockId: block.id, + pageId, + }); + + // 다음 문자를 위한 새 노드 생성 (마지막 문자가 아닌 경우에만) + if (index < characters.length - 1) { + block.crdt.localInsert(currentPosition + 1, "", block.id, pageId); + } + + currentPosition += 1; + }); - currentPosition += 1; - }); + block.crdt.currentCaret = currentCaret + characters.length; + } + isLocalChange.current = false; + isSameLocalChange.current = false; + }, + [editorCRDT, pageId, sendCharInsertOperation], + ); - block.crdt.currentCaret = currentCaret + characters.length; - } - isLocalChange.current = false; - isSameLocalChange.current = false; - }, - [editorCRDT, pageId, sendCharInsertOperation], - ); + const subscriptionRef = useRef(false); - const subscriptionRef = useRef(false); + useEffect(() => { + if (!editorCRDT || !editorCRDT.current.currentBlock) return; - useEffect(() => { - if (!editorCRDT || !editorCRDT.current.currentBlock) return; + const { activeElement } = document; + if (activeElement?.tagName.toLowerCase() === "input") { + return; // input에 포커스가 있으면 캐럿 위치 변경하지 않음 + } + 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()]); - const { activeElement } = document; - if (activeElement?.tagName.toLowerCase() === "input") { - return; // input에 포커스가 있으면 캐럿 위치 변경하지 않음 - } - 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()]); - - useEffect(() => { - if (!editorCRDT) return; - if (subscriptionRef.current) return; - subscriptionRef.current = true; - - const unsubscribe = subscribeToRemoteOperations({ - onRemoteBlockInsert: handleRemoteBlockInsert, - onRemoteBlockDelete: handleRemoteBlockDelete, - onRemoteCharInsert: handleRemoteCharInsert, - onRemoteCharDelete: handleRemoteCharDelete, - onRemoteBlockUpdate: handleRemoteBlockUpdate, - onRemoteBlockReorder: handleRemoteBlockReorder, - onRemoteCharUpdate: handleRemoteCharUpdate, - onRemoteCursor: handleRemoteCursor, - onRemoteBlockCheckbox: handleRemoteBlockCheckbox, - onBatchOperations: (batch) => { - for (const item of batch) { - switch (item.event) { - case "insert/block": - handleRemoteBlockInsert(item.operation); - break; - case "delete/block": - handleRemoteBlockDelete(item.operation); - break; - case "insert/char": - handleRemoteCharInsert(item.operation); - break; - case "delete/char": - handleRemoteCharDelete(item.operation); - break; - case "update/block": - handleRemoteBlockUpdate(item.operation); - break; - case "reorder/block": - handleRemoteBlockReorder(item.operation); - break; - case "update/char": - handleRemoteCharUpdate(item.operation); - break; - default: - console.warn("알 수 없는 연산 타입:", item.event); + useEffect(() => { + if (!editorCRDT) return; + if (subscriptionRef.current) return; + subscriptionRef.current = true; + + const unsubscribe = subscribeToRemoteOperations({ + onRemoteBlockInsert: handleRemoteBlockInsert, + onRemoteBlockDelete: handleRemoteBlockDelete, + onRemoteCharInsert: handleRemoteCharInsert, + onRemoteCharDelete: handleRemoteCharDelete, + onRemoteBlockUpdate: handleRemoteBlockUpdate, + onRemoteBlockReorder: handleRemoteBlockReorder, + onRemoteCharUpdate: handleRemoteCharUpdate, + onRemoteCursor: handleRemoteCursor, + onRemoteBlockCheckbox: handleRemoteBlockCheckbox, + onBatchOperations: (batch) => { + for (const item of batch) { + switch (item.event) { + case "insert/block": + handleRemoteBlockInsert(item.operation); + break; + case "delete/block": + handleRemoteBlockDelete(item.operation); + break; + case "insert/char": + handleRemoteCharInsert(item.operation); + break; + case "delete/char": + handleRemoteCharDelete(item.operation); + break; + case "update/block": + handleRemoteBlockUpdate(item.operation); + break; + case "reorder/block": + handleRemoteBlockReorder(item.operation); + break; + case "update/char": + handleRemoteCharUpdate(item.operation); + break; + default: + console.warn("알 수 없는 연산 타입:", item.event); + } } - } - }, - }); + }, + }); - return () => { - subscriptionRef.current = false; - unsubscribe?.(); - }; - }, [ - editorCRDT, - subscribeToRemoteOperations, - pageId, - handleRemoteBlockInsert, - handleRemoteBlockDelete, - handleRemoteCharInsert, - handleRemoteCharDelete, - handleRemoteBlockUpdate, - handleRemoteBlockReorder, - handleRemoteCharUpdate, - handleRemoteCursor, - ]); - - // 로딩 상태 체크 - if (!editorCRDT || !editorState) { - return
Loading editor data...
; - } - return ( -
-
- -
- { - handleDragEnd(event, dragBlockList, () => setDragBlockList([])); - }} - onDragStart={(event) => { - handleDragStart(event, setDragBlockList); - }} - sensors={sensors} - > - `${block.id.client}-${block.id.clock}`)} - strategy={verticalListSortingStrategy} + return () => { + subscriptionRef.current = false; + unsubscribe?.(); + }; + }, [ + editorCRDT, + subscribeToRemoteOperations, + pageId, + handleRemoteBlockInsert, + handleRemoteBlockDelete, + handleRemoteCharInsert, + handleRemoteCharDelete, + handleRemoteBlockUpdate, + handleRemoteBlockReorder, + handleRemoteCharUpdate, + handleRemoteCursor, + ]); + + // 로딩 상태 체크 + if (!editorCRDT || !editorState) { + return
Loading editor data...
; + } + return ( +
+
+ +
+ { + handleDragEnd(event, dragBlockList, () => setDragBlockList([])); + }} + onDragStart={(event) => { + handleDragStart(event, setDragBlockList); + }} + sensors={sensors} > - {editorState.linkedList.spread().map((block, idx) => ( - - ))} - - - {editorState.linkedList.spread().length === 0 && ( -
- 클릭해서 새로운 블록을 추가하세요 -
- )} + `${block.id.client}-${block.id.clock}`)} + strategy={verticalListSortingStrategy} + > + {editorState.linkedList.spread().map((block, idx) => ( + + ))} + + + {editorState.linkedList.spread().length === 0 && ( +
+ 클릭해서 새로운 블록을 추가하세요 +
+ )} +
-
- ); -}; + ); + }, + (prev, next) => { + // 리사이징 중에는 항상 리렌더링을 방지 + if (prev.isResizing) return true; + + // 일반적인 상황에서는 serializedEditorData가 변경될 때만 리렌더링 + return prev.serializedEditorData === next.serializedEditorData; + }, +); + +Editor.displayName = "Editor"; diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index d1dd326..c115fd2 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -266,11 +266,32 @@ export const Block: React.FC = memo( /> ); + const getTextAndStylesHash = (block: CRDTBlock) => { + const chars = block.crdt.LinkedList.spread(); + return JSON.stringify( + chars.map((char) => ({ + value: char.value, + style: char.style, + color: char.color, + backgroundColor: char.backgroundColor, + })), + ); + }; + useEffect(() => { if (blockRef.current) { + console.log(block.crdt.serialize()); setInnerHTML({ element: blockRef.current, block }); } - }, [block.crdt.serialize()]); + }, [getTextAndStylesHash(block)]); + + // useEffect(() => { + // console.log(block.crdt); + // if (blockRef.current) { + // console.log(block.crdt.LinkedList.spread()); + // setInnerHTML({ element: blockRef.current, block }); + // } + // }, [block.crdt.serialize()]); return ( // TODO: eslint 규칙을 수정해야 할까? diff --git a/client/src/features/editor/utils/domSyncUtils.ts b/client/src/features/editor/utils/domSyncUtils.ts index d636b67..984642c 100644 --- a/client/src/features/editor/utils/domSyncUtils.ts +++ b/client/src/features/editor/utils/domSyncUtils.ts @@ -58,77 +58,206 @@ const getClassNames = (state: TextStyleState): string => { return css(baseStyles); }; - export const setInnerHTML = ({ element, block }: SetInnerHTMLProps): void => { const chars = block.crdt.LinkedList.spread(); + const selection = window.getSelection(); + const range = selection?.getRangeAt(0); + let caretNode = range?.startContainer; + if (chars.length === 0) { - element.innerHTML = ""; + while (element.firstChild) { + element.removeChild(element.firstChild); + } return; } - // 각 위치별 모든 적용된 스타일을 추적 - const positionStyles: TextStyleState[] = chars.map((char) => { - const styleSet = new Set(); - - // 현재 문자의 스타일 수집 - char.style.forEach((style) => styleSet.add(TEXT_STYLES[style])); - - return { - styles: styleSet, - color: char.color, - backgroundColor: char.backgroundColor, - }; - }); - - let html = ""; + const fragment = document.createDocumentFragment(); + let currentSpan: HTMLSpanElement | null = null; + let currentText = ""; let currentState: TextStyleState = { styles: new Set(), color: "black", backgroundColor: "transparent", }; - let spanOpen = false; - chars.forEach((char, index) => { - const targetState = positionStyles[index]; + const hasStylesApplied = (state: TextStyleState): boolean => { + return ( + state.styles.size > 0 || state.color !== "black" || state.backgroundColor !== "transparent" + ); + }; + + const flushCurrentText = () => { + if (!currentText) return; + + // 현재 스타일이 적용된 상태라면 span으로 감싸서 추가 + if (hasStylesApplied(currentState) && currentSpan) { + currentSpan.appendChild(document.createTextNode(sanitizeText(currentText))); + fragment.appendChild(currentSpan); + } else { + // 스타일이 없다면 일반 텍스트 노드로 추가 + fragment.appendChild(document.createTextNode(sanitizeText(currentText))); + } + currentText = ""; + currentSpan = null; + }; + + chars.forEach((char) => { + const targetState = { + styles: new Set(char.style.map((style) => TEXT_STYLES[style])), + color: char.color, + backgroundColor: char.backgroundColor, + }; - // 스타일, 색상, 배경색 변경 확인 const styleChanged = !setsEqual(currentState.styles, targetState.styles) || currentState.color !== targetState.color || currentState.backgroundColor !== targetState.backgroundColor; - // 변경되었으면 현재 span 태그 닫기 - if (styleChanged && spanOpen) { - html += ""; - spanOpen = false; - } - - // 새로운 스타일 조합으로 span 태그 열기 + // 스타일이 변경되었다면 현재까지의 텍스트를 처리 if (styleChanged) { - const className = getClassNames(targetState); - html += ``; - spanOpen = true; + flushCurrentText(); + + // 새로운 스타일 상태 설정 + currentState = targetState; + + // 새로운 스타일이 있는 경우에만 span 생성 + if (hasStylesApplied(targetState)) { + currentSpan = document.createElement("span"); + currentSpan.className = getClassNames(targetState); + currentSpan.style.whiteSpace = "pre"; + } } - // 텍스트 추가 - html += sanitizeText(char.value); + currentText += char.value; + }); + + // 마지막 텍스트 처리 + flushCurrentText(); - // 다음 문자로 넘어가기 전에 현재 상태 업데이트 - currentState = targetState; + // DOM 업데이트 로직 + const existingNodes = Array.from(element.childNodes); + const newNodes = Array.from(fragment.childNodes); + let i = 0; - // 마지막 문자이고 span이 열려있으면 닫기 - if (index === chars.length - 1 && spanOpen) { - html += ""; - spanOpen = false; + // 공통 길이만큼 업데이트 또는 재사용 + const minLength = Math.min(existingNodes.length, newNodes.length); + for (; i < minLength; i++) { + if (!nodesAreEqual(existingNodes[i], newNodes[i])) { + if (caretNode === existingNodes[i]) { + // 캐럿이 있던 노드가 교체되는 경우, 새 노드에서 동일한 텍스트 위치 찾기 + caretNode = newNodes[i]; + } + element.replaceChild(newNodes[i], existingNodes[i]); } - }); + } - // DOM 업데이트 - if (element.innerHTML !== html) { - element.innerHTML = html; + // 남은 새 노드 추가 + for (; i < newNodes.length; i++) { + element.appendChild(newNodes[i]); + } + + // 남은 기존 노드 제거 + while (i < existingNodes.length) { + if (caretNode === existingNodes[i]) { + // 캐럿이 있던 노드가 제거되는 경우 + caretNode = undefined; + } + element.removeChild(existingNodes[i]); + i += 1; } }; +const nodesAreEqual = (node1: Node, node2: Node): boolean => { + if (node1.nodeType !== node2.nodeType) return false; + + if (node1.nodeType === Node.TEXT_NODE) { + return node1.textContent === node2.textContent; + } + + if (node1.nodeType === Node.ELEMENT_NODE) { + const elem1 = node1 as HTMLElement; + const elem2 = node2 as HTMLElement; + return ( + elem1.tagName === elem2.tagName && + elem1.className === elem2.className && + elem1.getAttribute("style") === elem2.getAttribute("style") && + elem1.textContent === elem2.textContent + ); + } + + return false; +}; + +// export const setInnerHTML = ({ element, block }: SetInnerHTMLProps): void => { +// const chars = block.crdt.LinkedList.spread(); +// if (chars.length === 0) { +// element.innerHTML = ""; +// return; +// } + +// // 각 위치별 모든 적용된 스타일을 추적 +// const positionStyles: TextStyleState[] = chars.map((char) => { +// const styleSet = new Set(); + +// // 현재 문자의 스타일 수집 +// char.style.forEach((style) => styleSet.add(TEXT_STYLES[style])); + +// return { +// styles: styleSet, +// color: char.color, +// backgroundColor: char.backgroundColor, +// }; +// }); + +// let html = ""; +// let currentState: TextStyleState = { +// styles: new Set(), +// color: "black", +// backgroundColor: "transparent", +// }; +// let spanOpen = false; + +// chars.forEach((char, index) => { +// const targetState = positionStyles[index]; + +// // 스타일, 색상, 배경색 변경 확인 +// const styleChanged = +// !setsEqual(currentState.styles, targetState.styles) || +// currentState.color !== targetState.color || +// currentState.backgroundColor !== targetState.backgroundColor; + +// // 변경되었으면 현재 span 태그 닫기 +// if (styleChanged && spanOpen) { +// html += ""; +// spanOpen = false; +// } + +// // 새로운 스타일 조합으로 span 태그 열기 +// if (styleChanged) { +// const className = getClassNames(targetState); +// html += ``; +// spanOpen = true; +// } + +// // 텍스트 추가 +// html += sanitizeText(char.value); + +// // 다음 문자로 넘어가기 전에 현재 상태 업데이트 +// currentState = targetState; + +// // 마지막 문자이고 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; diff --git a/client/src/features/page/Page.tsx b/client/src/features/page/Page.tsx index f6747f5..1954463 100644 --- a/client/src/features/page/Page.tsx +++ b/client/src/features/page/Page.tsx @@ -2,11 +2,13 @@ import { PageIconType, serializedEditorDataProps } from "@noctaCrdt/types/Interf import { motion, AnimatePresence } from "framer-motion"; import { useEffect, useState } from "react"; import { Editor } from "@features/editor/Editor"; -import { Page as PageType } from "@src/types/page"; +import { Page as PageType, DIRECTIONS, Direction } from "@src/types/page"; import { pageContainer, pageHeader, resizeHandles } from "./Page.style"; import { PageControlButton } from "./components/PageControlButton/PageControlButton"; +import { PageSkeletonUI } from "./components/PageSkeletonUI/PageSkeletonUI"; +import { useSkeletonPage } from "./components/PageSkeletonUI/useSkeletonPage"; import { PageTitle } from "./components/PageTitle/PageTitle"; -import { DIRECTIONS, usePage } from "./hooks/usePage"; +import { usePage } from "./hooks/usePage"; interface PageProps extends PageType { testKey: string; @@ -34,9 +36,26 @@ export const Page = ({ handleTitleChange, serializedEditorData, }: PageProps) => { - const { position, size, isMaximized, pageDrag, pageResize, pageMinimize, pageMaximize } = usePage( - { x, y }, + const { + position, + size, + isMaximized, + pageDrag, + pageMinimize, + pageMaximize, + isSidebarOpen, + handleResizeComplete, + } = usePage({ x, y }); + + const { isResizing, skeletonPosition, skeletonSize, handleSkeletonResizeStart } = useSkeletonPage( + { + initialPosition: position, + initialSize: size, + isSidebarOpen, + onApply: handleResizeComplete, + }, ); + const [serializedEditorDatas, setSerializedEditorDatas] = useState(serializedEditorData); @@ -54,6 +73,11 @@ export const Page = ({ } }; + const handleResizeStart = (e: React.MouseEvent, direction: Direction) => { + e.preventDefault(); + // 스켈레톤 UI의 리사이징을 시작 + handleSkeletonResizeStart(e, direction); + }; // serializedEditorData prop이 변경되면 local state도 업데이트 useEffect(() => { setSerializedEditorDatas(serializedEditorData); @@ -64,43 +88,64 @@ export const Page = ({ } return ( -
-
- - handlePageClose(id)} - onPageMaximize={pageMaximize} - onPageMinimize={pageMinimize} + +
+
+ + handlePageClose(id)} + onPageMaximize={pageMaximize} + onPageMinimize={pageMinimize} + /> +
+ + {DIRECTIONS.map((direction) => ( + handleResizeStart(e, direction)} + /> + ))}
- - {DIRECTIONS.map((direction) => ( - pageResize(e, direction)} + + {isResizing && ( + + - ))} -
+ + )} ); }; diff --git a/client/src/features/page/components/PageControlButton/PageControlButton.style.ts b/client/src/features/page/components/PageControlButton/PageControlButton.style.ts index 42b79b3..59afad8 100644 --- a/client/src/features/page/components/PageControlButton/PageControlButton.style.ts +++ b/client/src/features/page/components/PageControlButton/PageControlButton.style.ts @@ -5,7 +5,6 @@ export const pageControlContainer = css({ gap: "sm", _hover: { "& svg": { - transform: "scale(1)", // 추가 효과 opacity: 1, }, }, diff --git a/client/src/features/page/components/PageSkeletonUI/PageSkeletonUI.style.ts b/client/src/features/page/components/PageSkeletonUI/PageSkeletonUI.style.ts new file mode 100644 index 0000000..90cdd5e --- /dev/null +++ b/client/src/features/page/components/PageSkeletonUI/PageSkeletonUI.style.ts @@ -0,0 +1,89 @@ +import { defineKeyframes } from "@pandacss/dev"; +import { css, cx, cva } from "@styled-system/css"; +import { glassContainer } from "@styled-system/recipes"; + +export const keyframes = defineKeyframes({ + moveGradient: { + "50%": { backgroundPosition: "100% 50%" }, + }, +}); + +export const pageSkeletonContainer = cx( + glassContainer({ border: "lg" }), + css({ + display: "flex", + position: "relative", + flexDirection: "column", + border: "2px dashed gray", + borderRadius: "24px", + width: "450px", + height: "400px", + overflow: "hidden", + }), +); + +export const pageTitleContainer = css({ + display: "flex", + gap: "8px", + flexDirection: "row", + alignItems: "center", + overflow: "hidden", +}); + +export const pageHeader = css({ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + borderTopRadius: "md", + height: "60px", + padding: "sm", + boxShadow: "xs", + backdropFilter: "blur(30px)", + "&:hover": { + cursor: "move", + }, +}); + +export const pageControlContainer = css({ + display: "flex", + gap: "sm", + _hover: { + "& svg": { + opacity: 1, + }, + }, +}); + +export const pageControlButton = cva({ + base: { + display: "flex", + justifyContent: "center", + alignItems: "center", + borderRadius: "full", + width: "20px", + height: "20px", + cursor: "pointer", + "&:disabled": { + background: "gray.400", + opacity: 0.5, + cursor: "not-allowed", + }, + }, + variants: { + color: { + yellow: { background: "yellow" }, + green: { background: "green" }, + red: { background: "red" }, + }, + }, +}); + +export const pageTitle = css({ + textStyle: "display-medium24", + alignItems: "center", + paddingTop: "3px", + color: "gray.500", + textOverflow: "ellipsis", + overflow: "hidden", + whiteSpace: "nowrap", +}); diff --git a/client/src/features/page/components/PageSkeletonUI/PageSkeletonUI.tsx b/client/src/features/page/components/PageSkeletonUI/PageSkeletonUI.tsx new file mode 100644 index 0000000..ed81d87 --- /dev/null +++ b/client/src/features/page/components/PageSkeletonUI/PageSkeletonUI.tsx @@ -0,0 +1,65 @@ +import { PageIconType } from "@noctaCrdt/types/Interfaces"; +import { iconComponents } from "@src/constants/PageIconButton.config"; +import { Direction, Size, Position } from "@src/types/page"; +import { + pageSkeletonContainer, + pageTitleContainer, + pageHeader, + pageControlContainer, + pageControlButton, + pageTitle, +} from "./PageSkeletonUI.style"; + +interface PageSkeletonUIProps { + id: string; + title: string; + icon: PageIconType; + testKey: string; + position: Position; + size: Size; + zIndex: number; + onDragStart: (e: React.PointerEvent) => void; + onResizeStart: (e: React.MouseEvent, direction: Direction) => void; +} + +export const PageSkeletonUI = ({ + id, + title, + icon, + testKey, + position, + size, + zIndex, + onDragStart, +}: PageSkeletonUIProps) => { + const { icon: IconComponent, color } = iconComponents[icon]; + return ( +
+
+
+ +

{title || "Title"}

+
+
+
+
+
+
+
+
+ ); +}; diff --git a/client/src/features/page/components/PageSkeletonUI/useSkeletonPage.ts b/client/src/features/page/components/PageSkeletonUI/useSkeletonPage.ts new file mode 100644 index 0000000..ebe7a68 --- /dev/null +++ b/client/src/features/page/components/PageSkeletonUI/useSkeletonPage.ts @@ -0,0 +1,200 @@ +// hooks/useSkeletonPage.ts +import { useRef, useState, useEffect } from "react"; +import { SIDE_BAR, PAGE } from "@constants/size"; +import { SPACING } from "@constants/spacing"; +import { Direction, Position, Size } from "@src/types/page"; + +const PADDING = SPACING.MEDIUM * 2; + +interface UseSkeletonPageProps { + initialPosition: Position; + initialSize: Size; + isSidebarOpen: boolean; + onApply: (size: Size, position: Position) => void; +} + +export const useSkeletonPage = ({ + initialPosition, + initialSize, + isSidebarOpen, + onApply, +}: UseSkeletonPageProps) => { + const [isResizing, setIsResizing] = useState(false); + const [skeletonSize, setSkeletonSize] = useState(initialSize); + const [skeletonPosition, setSkeletonPosition] = useState(initialPosition); + + // 리사이즈 관련 ref + const startX = useRef(0); + const startY = useRef(0); + const startWidth = useRef(0); + const startHeight = useRef(0); + const startPosition = useRef({ x: 0, y: 0 }); + const resizeDirection = useRef(null); + + const getSidebarWidth = () => (isSidebarOpen ? SIDE_BAR.WIDTH : SIDE_BAR.MIN_WIDTH); + + const handleSkeletonResizeStart = (e: React.MouseEvent, direction: Direction) => { + e.preventDefault(); + setIsResizing(true); + + // 초기값 설정 + startX.current = e.clientX; + startY.current = e.clientY; + startWidth.current = initialSize.width; + startHeight.current = initialSize.height; + startPosition.current = { ...initialPosition }; + resizeDirection.current = direction; + + // 스켈레톤 초기 상태 설정 + setSkeletonPosition(initialPosition); + setSkeletonSize(initialSize); + }; + + const handleResizeMove = (e: MouseEvent) => { + if (!isResizing || !resizeDirection.current) return; + + const deltaX = e.clientX - startX.current; + const deltaY = e.clientY - startY.current; + const sidebarWidth = getSidebarWidth(); + + let newWidth = startWidth.current; + let newHeight = startHeight.current; + let newX = startPosition.current.x; + let newY = startPosition.current.y; + + switch (resizeDirection.current) { + case "right": { + newWidth = Math.min( + window.innerWidth - startPosition.current.x - sidebarWidth - PADDING, + Math.max(PAGE.MIN_WIDTH, startWidth.current + deltaX), + ); + break; + } + + case "left": { + const possibleWidth = Math.min( + startPosition.current.x + startWidth.current, + Math.max(PAGE.MIN_WIDTH, startWidth.current - deltaX), + ); + newX = Math.max(0, startPosition.current.x + startWidth.current - possibleWidth); + newWidth = possibleWidth; + break; + } + + case "bottom": { + newHeight = Math.min( + window.innerHeight - startPosition.current.y - PADDING, + Math.max(PAGE.MIN_HEIGHT, startHeight.current + deltaY), + ); + break; + } + + case "top": { + const possibleHeight = Math.min( + startPosition.current.y + startHeight.current, + Math.max(PAGE.MIN_HEIGHT, startHeight.current - deltaY), + ); + newY = Math.max(0, startPosition.current.y + startHeight.current - possibleHeight); + newHeight = possibleHeight; + break; + } + + case "topLeft": { + // 높이 계산 + const possibleHeight = Math.min( + startPosition.current.y + startHeight.current, + Math.max(PAGE.MIN_HEIGHT, startHeight.current - deltaY), + ); + newY = Math.max(0, startPosition.current.y + startHeight.current - possibleHeight); + newHeight = possibleHeight; + + // 너비 계산 + const possibleWidth = Math.min( + startPosition.current.x + startWidth.current, + Math.max(PAGE.MIN_WIDTH, startWidth.current - deltaX), + ); + newX = Math.max(0, startPosition.current.x + startWidth.current - possibleWidth); + newWidth = possibleWidth; + break; + } + + case "topRight": { + // 높이 계산 + const possibleHeight = Math.min( + startPosition.current.y + startHeight.current, + Math.max(PAGE.MIN_HEIGHT, startHeight.current - deltaY), + ); + newY = Math.max(0, startPosition.current.y + startHeight.current - possibleHeight); + newHeight = possibleHeight; + + // 너비 계산 + newWidth = Math.min( + window.innerWidth - startPosition.current.x - sidebarWidth - PADDING, + Math.max(PAGE.MIN_WIDTH, startWidth.current + deltaX), + ); + break; + } + + case "bottomLeft": { + // 높이 계산 + newHeight = Math.min( + window.innerHeight - startPosition.current.y - PADDING, + Math.max(PAGE.MIN_HEIGHT, startHeight.current + deltaY), + ); + + // 너비 계산 + const possibleWidth = Math.min( + startPosition.current.x + startWidth.current, + Math.max(PAGE.MIN_WIDTH, startWidth.current - deltaX), + ); + newX = Math.max(0, startPosition.current.x + startWidth.current - possibleWidth); + newWidth = possibleWidth; + break; + } + + case "bottomRight": { + newHeight = Math.min( + window.innerHeight - startPosition.current.y - PADDING, + Math.max(PAGE.MIN_HEIGHT, startHeight.current + deltaY), + ); + + newWidth = Math.min( + window.innerWidth - startPosition.current.x - sidebarWidth - PADDING, + Math.max(PAGE.MIN_WIDTH, startWidth.current + deltaX), + ); + break; + } + } + + setSkeletonSize({ width: newWidth, height: newHeight }); + setSkeletonPosition({ x: newX, y: newY }); + }; + + const handleResizeEnd = () => { + if (!isResizing) return; + + // 최종 크기와 위치를 실제 페이지에 적용 + onApply(skeletonSize, skeletonPosition); + setIsResizing(false); + resizeDirection.current = null; + }; + + useEffect(() => { + if (isResizing) { + window.addEventListener("mousemove", handleResizeMove); + window.addEventListener("mouseup", handleResizeEnd); + + return () => { + window.removeEventListener("mousemove", handleResizeMove); + window.removeEventListener("mouseup", handleResizeEnd); + }; + } + }, [isResizing, skeletonSize, skeletonPosition]); + + return { + isResizing, + skeletonPosition, + skeletonSize, + handleSkeletonResizeStart, + }; +}; diff --git a/client/src/features/page/hooks/usePage.ts b/client/src/features/page/hooks/usePage.ts index 7642a42..b22fae3 100644 --- a/client/src/features/page/hooks/usePage.ts +++ b/client/src/features/page/hooks/usePage.ts @@ -5,18 +5,6 @@ import { Position, Size } from "@src/types/page"; import { useIsSidebarOpen } from "@stores/useSidebarStore"; const PADDING = SPACING.MEDIUM * 2; -export const DIRECTIONS = [ - "top", - "bottom", - "left", - "right", - "topLeft", - "topRight", - "bottomLeft", - "bottomRight", -] as const; - -type Direction = (typeof DIRECTIONS)[number]; // 만약 maximize 상태면, 화면이 커질때도 꽉 촤게 해줘야함. export const usePage = ({ x, y }: Position) => { @@ -62,128 +50,6 @@ export const usePage = ({ x, y }: Position) => { document.addEventListener("pointerup", handleDragEnd); }; - const pageResize = (e: React.MouseEvent, direction: Direction) => { - e.preventDefault(); - const startX = e.clientX; - const startY = e.clientY; - const startWidth = size.width; - const startHeight = size.height; - const startPosition = { x: position.x, y: position.y }; - - const resize = (e: MouseEvent) => { - const deltaX = e.clientX - startX; - const deltaY = e.clientY - startY; - - let newWidth = startWidth; - let newHeight = startHeight; - let newX = startPosition.x; - let newY = startPosition.y; - - switch (direction) { - case "right": { - newWidth = Math.min( - window.innerWidth - startPosition.x - getSidebarWidth() - PADDING, - Math.max(PAGE.MIN_WIDTH, startWidth + deltaX), - ); - break; - } - - case "left": { - newWidth = Math.min( - startPosition.x + startWidth, - Math.max(PAGE.MIN_WIDTH, startWidth - deltaX), - ); - newX = Math.max(0, startPosition.x + startWidth - newWidth); - break; - } - - case "bottom": { - newHeight = Math.min( - window.innerHeight - startPosition.y - PADDING, - Math.max(PAGE.MIN_HEIGHT, startHeight + deltaY), - ); - break; - } - - case "top": { - newHeight = Math.min( - startPosition.y + startHeight, - Math.max(PAGE.MIN_HEIGHT, startHeight - deltaY), - ); - newY = Math.max(0, startPosition.y + startHeight - newHeight); - break; - } - - case "topLeft": { - newHeight = Math.min( - startPosition.y + startHeight, - Math.max(PAGE.MIN_HEIGHT, startHeight - deltaY), - ); - newY = Math.max(0, startPosition.y + startHeight - newHeight); - - newWidth = Math.min( - startPosition.x + startWidth, - Math.max(PAGE.MIN_WIDTH, startWidth - deltaX), - ); - newX = Math.max(0, startPosition.x + startWidth - newWidth); - break; - } - - case "topRight": { - newHeight = Math.min( - startPosition.y + startHeight, - Math.max(PAGE.MIN_HEIGHT, startHeight - deltaY), - ); - newY = Math.max(0, startPosition.y + startHeight - newHeight); - - newWidth = Math.min( - window.innerWidth - startPosition.x - getSidebarWidth() - PADDING, - Math.max(PAGE.MIN_WIDTH, startWidth + deltaX), - ); - break; - } - - case "bottomLeft": { - newHeight = Math.min( - window.innerHeight - startPosition.y - PADDING, - Math.max(PAGE.MIN_HEIGHT, startHeight + deltaY), - ); - - newWidth = Math.min( - startPosition.x + startWidth, - Math.max(PAGE.MIN_WIDTH, startWidth - deltaX), - ); - newX = Math.max(0, startPosition.x + startWidth - newWidth); - break; - } - - case "bottomRight": { - newHeight = Math.min( - window.innerHeight - startPosition.y - PADDING, - Math.max(PAGE.MIN_HEIGHT, startHeight + deltaY), - ); - - newWidth = Math.min( - window.innerWidth - startPosition.x - getSidebarWidth() - PADDING, - Math.max(PAGE.MIN_WIDTH, startWidth + deltaX), - ); - break; - } - } - - setSize({ width: newWidth, height: newHeight }); - setPosition({ x: newX, y: newY }); - }; - - const stopResize = () => { - document.removeEventListener("mousemove", resize); - document.removeEventListener("mouseup", stopResize); - }; - - document.addEventListener("mousemove", resize); - document.addEventListener("mouseup", stopResize); - }; - const pageMinimize = () => { setSize({ width: PAGE.MIN_WIDTH, @@ -283,6 +149,11 @@ export const usePage = ({ x, y }: Position) => { }; }, [isMaximized, isSidebarOpen]); // maximize 상태와 sidebar 상태만 의존성 + const handleResizeComplete = (newSize: Size, newPosition: Position) => { + setSize(newSize); + setPosition(newPosition); + }; + // 일반 상태일 때의 resize 처리 useEffect(() => { if (isMaximized) return; @@ -308,9 +179,10 @@ export const usePage = ({ x, y }: Position) => { position, size, pageDrag, - pageResize, pageMinimize, pageMaximize, isMaximized, + isSidebarOpen, + handleResizeComplete, }; }; diff --git a/client/src/types/page.ts b/client/src/types/page.ts index 3fd9ad1..2c309c8 100644 --- a/client/src/types/page.ts +++ b/client/src/types/page.ts @@ -22,3 +22,16 @@ export interface Size { width: number; height: number; } + +export const DIRECTIONS = [ + "top", + "bottom", + "left", + "right", + "topLeft", + "topRight", + "bottomLeft", + "bottomRight", +] as const; + +export type Direction = (typeof DIRECTIONS)[number];