From c045a85aea7dda6a84fe02201b2b73b2597aedca Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Thu, 16 Jan 2025 16:50:38 +0900 Subject: [PATCH 01/12] =?UTF-8?q?refactor:=20innerhtml=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EB=B0=A9=EC=8B=9D=EC=97=90=EC=84=9C=20dom=20api?= =?UTF-8?q?=EB=A5=BC=20=ED=86=B5=ED=95=9C=20dom=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: minjungw00 Co-authored-by: Jang seo yun --- .../editor/components/block/Block.tsx | 29 ++- .../src/features/editor/utils/domSyncUtils.ts | 203 +++++++++++++++--- 2 files changed, 197 insertions(+), 35 deletions(-) diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index d1dd326..bea3720 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -11,6 +11,7 @@ import { import { motion } from "framer-motion"; import { memo, useEffect, useRef, useState } from "react"; import { useModal } from "@src/components/modal/useModal"; +import { textStyles } from "@src/styles/typography"; import { getAbsoluteCaretPosition } from "@src/utils/caretUtils"; import { useBlockAnimation } from "../../hooks/useBlockAnimtaion"; import { setInnerHTML, getTextOffset } from "../../utils/domSyncUtils"; @@ -106,6 +107,11 @@ export const Block: React.FC = memo( block, }, }); + const [textStyle, setTextStyle] = useState( + block.crdt.LinkedList.spread() + .map((char) => char.style) + .join(""), + ); // 현재 드래그 중인 부모 블록의 indent 확인 const isChildOfDragging = dragBlockList.some((item) => item === data.id); @@ -266,11 +272,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..5cec902 100644 --- a/client/src/features/editor/utils/domSyncUtils.ts +++ b/client/src/features/editor/utils/domSyncUtils.ts @@ -61,18 +61,22 @@ const getClassNames = (state: TextStyleState): string => { 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; + const caretOffset = range?.startOffset; + 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, @@ -80,55 +84,186 @@ export const setInnerHTML = ({ element, block }: SetInnerHTMLProps): void => { }; }); - let html = ""; + const fragment = document.createDocumentFragment(); + let currentSpan: HTMLSpanElement | null = null; let currentState: TextStyleState = { styles: new Set(), color: "black", backgroundColor: "transparent", }; - let spanOpen = false; + + // 캐럿이 있던 노드의 텍스트 내용과 오프셋 저장 + // const caretNodeText = caretNode?.textContent || ""; chars.forEach((char, index) => { const targetState = positionStyles[index]; + const hasStyles = + targetState.styles.size > 0 || + targetState.color !== "black" || + targetState.backgroundColor !== "transparent"; - // 스타일, 색상, 배경색 변경 확인 - const styleChanged = - !setsEqual(currentState.styles, targetState.styles) || - currentState.color !== targetState.color || - currentState.backgroundColor !== targetState.backgroundColor; + if (hasStyles) { + const styleChanged = + !setsEqual(currentState.styles, targetState.styles) || + currentState.color !== targetState.color || + currentState.backgroundColor !== targetState.backgroundColor; - // 변경되었으면 현재 span 태그 닫기 - if (styleChanged && spanOpen) { - html += ""; - spanOpen = false; - } + if (styleChanged || !currentSpan) { + currentSpan = document.createElement("span"); + currentSpan.className = getClassNames(targetState); + currentSpan.style.whiteSpace = "pre"; + fragment.appendChild(currentSpan); + currentState = targetState; + } - // 새로운 스타일 조합으로 span 태그 열기 - if (styleChanged) { - const className = getClassNames(targetState); - html += ``; - spanOpen = true; + const textNode = document.createTextNode(sanitizeText(char.value)); + currentSpan.appendChild(textNode); + } else { + currentSpan = null; + const textNode = document.createTextNode(sanitizeText(char.value)); + fragment.appendChild(textNode); } + }); + + // DOM 업데이트를 위한 노드 비교 및 변경 + const existingNodes = Array.from(element.childNodes); + const newNodes = Array.from(fragment.childNodes); + let i = 0; - // 텍스트 추가 - html += sanitizeText(char.value); + // 공통 길이만큼 업데이트 또는 재사용 + 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]); + } + } - // 다음 문자로 넘어가기 전에 현재 상태 업데이트 - currentState = targetState; + // 남은 새 노드 추가 + for (; i < newNodes.length; i++) { + element.appendChild(newNodes[i]); + } - // 마지막 문자이고 span이 열려있으면 닫기 - if (index === chars.length - 1 && spanOpen) { - html += ""; - spanOpen = false; + // 남은 기존 노드 제거 + while (i < existingNodes.length) { + if (caretNode === existingNodes[i]) { + // 캐럿이 있던 노드가 제거되는 경우 + caretNode = undefined; } - }); + element.removeChild(existingNodes[i]); + i += 1; + } + + // 캐럿 위치 복원 + // if (caretNode && typeof caretOffset === "number" && selection) { + // try { + // // 새로운 노드에서 캐럿 위치 설정 + // const newRange = document.createRange(); + // const newOffset = Math.min(caretOffset, caretNode.textContent?.length || 0); + // newRange.setStart(caretNode, newOffset); + // newRange.collapse(true); + // selection.removeAllRanges(); + // selection.addRange(newRange); + // } catch (error) { + // console.error("Error restoring caret position:", error); + // } + // } +}; + +const nodesAreEqual = (node1: Node, node2: Node): boolean => { + if (node1.nodeType !== node2.nodeType) return false; - // DOM 업데이트 - if (element.innerHTML !== html) { - element.innerHTML = html; + 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; From ecc53e27dd7c8658383fda53cc54a5ac9e8d5e4d Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 20 Jan 2025 13:03:46 +0900 Subject: [PATCH 02/12] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=83=80=EC=9D=BC?= =?UTF-8?q?=20=EC=97=86=EB=8A=94=20=ED=85=8D=EC=8A=A4=ED=8A=B8=EB=85=B8?= =?UTF-8?q?=EB=93=9C=EB=81=BC=EB=A6=AC=20=EB=B3=91=ED=95=A9=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/editor/utils/domSyncUtils.ts | 98 +++++++++---------- 1 file changed, 46 insertions(+), 52 deletions(-) diff --git a/client/src/features/editor/utils/domSyncUtils.ts b/client/src/features/editor/utils/domSyncUtils.ts index 5cec902..984642c 100644 --- a/client/src/features/editor/utils/domSyncUtils.ts +++ b/client/src/features/editor/utils/domSyncUtils.ts @@ -58,14 +58,11 @@ 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; - const caretOffset = range?.startOffset; if (chars.length === 0) { while (element.firstChild) { @@ -74,58 +71,70 @@ export const setInnerHTML = ({ element, block }: SetInnerHTMLProps): void => { 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, - }; - }); - const fragment = document.createDocumentFragment(); let currentSpan: HTMLSpanElement | null = null; + let currentText = ""; let currentState: TextStyleState = { styles: new Set(), color: "black", backgroundColor: "transparent", }; - // 캐럿이 있던 노드의 텍스트 내용과 오프셋 저장 - // const caretNodeText = caretNode?.textContent || ""; + 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; - chars.forEach((char, index) => { - const targetState = positionStyles[index]; - const hasStyles = - targetState.styles.size > 0 || - targetState.color !== "black" || - targetState.backgroundColor !== "transparent"; + // 스타일이 변경되었다면 현재까지의 텍스트를 처리 + if (styleChanged) { + flushCurrentText(); - if (hasStyles) { - const styleChanged = - !setsEqual(currentState.styles, targetState.styles) || - currentState.color !== targetState.color || - currentState.backgroundColor !== targetState.backgroundColor; + // 새로운 스타일 상태 설정 + currentState = targetState; - if (styleChanged || !currentSpan) { + // 새로운 스타일이 있는 경우에만 span 생성 + if (hasStylesApplied(targetState)) { currentSpan = document.createElement("span"); currentSpan.className = getClassNames(targetState); currentSpan.style.whiteSpace = "pre"; - fragment.appendChild(currentSpan); - currentState = targetState; } - - const textNode = document.createTextNode(sanitizeText(char.value)); - currentSpan.appendChild(textNode); - } else { - currentSpan = null; - const textNode = document.createTextNode(sanitizeText(char.value)); - fragment.appendChild(textNode); } + + currentText += char.value; }); - // DOM 업데이트를 위한 노드 비교 및 변경 + // 마지막 텍스트 처리 + flushCurrentText(); + + // DOM 업데이트 로직 const existingNodes = Array.from(element.childNodes); const newNodes = Array.from(fragment.childNodes); let i = 0; @@ -156,21 +165,6 @@ export const setInnerHTML = ({ element, block }: SetInnerHTMLProps): void => { element.removeChild(existingNodes[i]); i += 1; } - - // 캐럿 위치 복원 - // if (caretNode && typeof caretOffset === "number" && selection) { - // try { - // // 새로운 노드에서 캐럿 위치 설정 - // const newRange = document.createRange(); - // const newOffset = Math.min(caretOffset, caretNode.textContent?.length || 0); - // newRange.setStart(caretNode, newOffset); - // newRange.collapse(true); - // selection.removeAllRanges(); - // selection.addRange(newRange); - // } catch (error) { - // console.error("Error restoring caret position:", error); - // } - // } }; const nodesAreEqual = (node1: Node, node2: Node): boolean => { From b17dd8d363f46d97acee08527b979c7db7a079fd Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 20 Jan 2025 15:47:18 +0900 Subject: [PATCH 03/12] =?UTF-8?q?refactor:=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=20=EB=B2=84=ED=8A=BC=20transform?= =?UTF-8?q?=20=EB=8F=99=EC=9E=91=20=EC=A1=B0=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/PageControlButton/PageControlButton.style.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/src/features/page/components/PageControlButton/PageControlButton.style.ts b/client/src/features/page/components/PageControlButton/PageControlButton.style.ts index 42b79b3..359bf7b 100644 --- a/client/src/features/page/components/PageControlButton/PageControlButton.style.ts +++ b/client/src/features/page/components/PageControlButton/PageControlButton.style.ts @@ -5,10 +5,12 @@ export const pageControlContainer = css({ gap: "sm", _hover: { "& svg": { - transform: "scale(1)", // 추가 효과 opacity: 1, }, }, + "& svg": { + transform: "scale(1)", + }, }); export const pageControlButton = cva({ From 4809ab175d121211ef4bfaf9294d66279d52523f Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 20 Jan 2025 15:58:59 +0900 Subject: [PATCH 04/12] =?UTF-8?q?chore:=20lint=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/editor/components/block/Block.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index bea3720..72f47ca 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -107,11 +107,6 @@ export const Block: React.FC = memo( block, }, }); - const [textStyle, setTextStyle] = useState( - block.crdt.LinkedList.spread() - .map((char) => char.style) - .join(""), - ); // 현재 드래그 중인 부모 블록의 indent 확인 const isChildOfDragging = dragBlockList.some((item) => item === data.id); From 1adbc9ff0fe54c6479a0e1b253d7486243aa6592 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 20 Jan 2025 16:00:35 +0900 Subject: [PATCH 05/12] =?UTF-8?q?chore:=20lint=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/editor/components/block/Block.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index 72f47ca..c115fd2 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -11,7 +11,6 @@ import { import { motion } from "framer-motion"; import { memo, useEffect, useRef, useState } from "react"; import { useModal } from "@src/components/modal/useModal"; -import { textStyles } from "@src/styles/typography"; import { getAbsoluteCaretPosition } from "@src/utils/caretUtils"; import { useBlockAnimation } from "../../hooks/useBlockAnimtaion"; import { setInnerHTML, getTextOffset } from "../../utils/domSyncUtils"; From a6c70c2d01bb900e1aecfe212e6df4eab13ebec0 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Mon, 20 Jan 2025 16:27:14 +0900 Subject: [PATCH 06/12] =?UTF-8?q?refactor:=20transform=20=EC=86=8D?= =?UTF-8?q?=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/PageControlButton/PageControlButton.style.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/src/features/page/components/PageControlButton/PageControlButton.style.ts b/client/src/features/page/components/PageControlButton/PageControlButton.style.ts index 359bf7b..59afad8 100644 --- a/client/src/features/page/components/PageControlButton/PageControlButton.style.ts +++ b/client/src/features/page/components/PageControlButton/PageControlButton.style.ts @@ -8,9 +8,6 @@ export const pageControlContainer = css({ opacity: 1, }, }, - "& svg": { - transform: "scale(1)", - }, }); export const pageControlButton = cva({ From 623364d7a74584979af7b8e61eee91fa7fea89f6 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Wed, 22 Jan 2025 16:48:41 +0900 Subject: [PATCH 07/12] =?UTF-8?q?refactor:=20Editor=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=A9=94=EB=AA=A8=EC=9D=B4=EC=A0=9C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=EC=9D=84=20=ED=86=B5=ED=95=9C=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/editor/Editor.tsx | 702 +++++++++++++------------- 1 file changed, 359 insertions(+), 343 deletions(-) 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"; From c3403084de396d43c34803aad11ed8ccace667dc Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Wed, 22 Jan 2025 16:49:22 +0900 Subject: [PATCH 08/12] =?UTF-8?q?feat:=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=88=EB=A0=88=ED=86=A4=20UI=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PageSkeletonUI/PageSkeletonUI.tsx | 65 +++++++++++++++++++ client/src/types/page.ts | 13 ++++ 2 files changed, 78 insertions(+) create mode 100644 client/src/features/page/components/PageSkeletonUI/PageSkeletonUI.tsx 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/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]; From be5defa66f4163828014e57ee5b061e89b6bd1e4 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Wed, 22 Jan 2025 16:49:54 +0900 Subject: [PATCH 09/12] =?UTF-8?q?design:=20=EC=8A=A4=EC=BC=88=EB=A0=88?= =?UTF-8?q?=ED=86=A4=20ui=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PageSkeletonUI/PageSkeletonUI.style.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 client/src/features/page/components/PageSkeletonUI/PageSkeletonUI.style.ts 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", +}); From a40415f5f8e223f7c1713a961a40315f2b91576f Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Wed, 22 Jan 2025 16:51:04 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat:=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A7=95=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 페이지 리사이징은 스켈레톤 UI에서 담당(useSkeletonPage 훅) - 기존 리사이징 로직(usePage훅) useSkeletonPage 훅으로 분리 - 리사이징이 완료되면, 해당 사이즈를 실제 페이지에 적용해 렌더링 최적화 --- .../PageSkeletonUI/useSkeletonPage.ts | 200 ++++++++++++++++++ client/src/features/page/hooks/usePage.ts | 142 +------------ 2 files changed, 207 insertions(+), 135 deletions(-) create mode 100644 client/src/features/page/components/PageSkeletonUI/useSkeletonPage.ts 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, }; }; From c636c86975132e431db09d7bc656a216e097540d Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Wed, 22 Jan 2025 16:51:16 +0900 Subject: [PATCH 11/12] =?UTF-8?q?feat:=20=EC=8A=A4=EC=BC=88=EB=A0=88?= =?UTF-8?q?=ED=86=A4=20UI=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/page/Page.tsx | 121 ++++++++++++++++++++---------- 1 file changed, 83 insertions(+), 38 deletions(-) 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 && ( + + - ))} -
+ + )} ); }; From 7bb06fd423e66f1843d8552b308941b89ce6fd52 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Wed, 22 Jan 2025 16:54:27 +0900 Subject: [PATCH 12/12] =?UTF-8?q?refactor:=20=EC=88=9C=EC=84=9C=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=A0=95=EB=A0=AC=EC=8B=9C=20tom?= =?UTF-8?q?estone=20=EB=85=B8=EB=93=9C=20=ED=99=95=EC=9D=B8=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- @noctaCrdt/src/LinkedList.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) 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부터 시작