From c045a85aea7dda6a84fe02201b2b73b2597aedca Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Thu, 16 Jan 2025 16:50:38 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor:=20innerhtml=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=97=90=EC=84=9C=20dom=20api=EB=A5=BC=20?= =?UTF-8?q?=ED=86=B5=ED=95=9C=20dom=20=EC=88=98=EC=A0=95=20=EB=B0=A9?= =?UTF-8?q?=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 2/6] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=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 3/6] =?UTF-8?q?refactor:=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=20=EB=B2=84=ED=8A=BC=20transform=20?= =?UTF-8?q?=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 4/6] =?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 5/6] =?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 6/6] =?UTF-8?q?refactor:=20transform=20=EC=86=8D=EC=84=B1?= =?UTF-8?q?=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({