Skip to content

Commit c045a85

Browse files
Ludovico7minjungw00pipisebastian
committed
refactor: innerhtml 변경방식에서 dom api를 통한 dom 수정 방식으로 변경
Co-authored-by: minjungw00 <minjungw00@naver.com> Co-authored-by: Jang seo yun <pipisebastian@users.noreply.github.com>
1 parent 3141337 commit c045a85

File tree

2 files changed

+197
-35
lines changed

2 files changed

+197
-35
lines changed

client/src/features/editor/components/block/Block.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { motion } from "framer-motion";
1212
import { memo, useEffect, useRef, useState } from "react";
1313
import { useModal } from "@src/components/modal/useModal";
14+
import { textStyles } from "@src/styles/typography";
1415
import { getAbsoluteCaretPosition } from "@src/utils/caretUtils";
1516
import { useBlockAnimation } from "../../hooks/useBlockAnimtaion";
1617
import { setInnerHTML, getTextOffset } from "../../utils/domSyncUtils";
@@ -106,6 +107,11 @@ export const Block: React.FC<BlockProps> = memo(
106107
block,
107108
},
108109
});
110+
const [textStyle, setTextStyle] = useState<string>(
111+
block.crdt.LinkedList.spread()
112+
.map((char) => char.style)
113+
.join(""),
114+
);
109115

110116
// 현재 드래그 중인 부모 블록의 indent 확인
111117
const isChildOfDragging = dragBlockList.some((item) => item === data.id);
@@ -266,11 +272,32 @@ export const Block: React.FC<BlockProps> = memo(
266272
/>
267273
);
268274

275+
const getTextAndStylesHash = (block: CRDTBlock) => {
276+
const chars = block.crdt.LinkedList.spread();
277+
return JSON.stringify(
278+
chars.map((char) => ({
279+
value: char.value,
280+
style: char.style,
281+
color: char.color,
282+
backgroundColor: char.backgroundColor,
283+
})),
284+
);
285+
};
286+
269287
useEffect(() => {
270288
if (blockRef.current) {
289+
console.log(block.crdt.serialize());
271290
setInnerHTML({ element: blockRef.current, block });
272291
}
273-
}, [block.crdt.serialize()]);
292+
}, [getTextAndStylesHash(block)]);
293+
294+
// useEffect(() => {
295+
// console.log(block.crdt);
296+
// if (blockRef.current) {
297+
// console.log(block.crdt.LinkedList.spread());
298+
// setInnerHTML({ element: blockRef.current, block });
299+
// }
300+
// }, [block.crdt.serialize()]);
274301

275302
return (
276303
// TODO: eslint 규칙을 수정해야 할까?

client/src/features/editor/utils/domSyncUtils.ts

Lines changed: 169 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -61,74 +61,209 @@ const getClassNames = (state: TextStyleState): string => {
6161

6262
export const setInnerHTML = ({ element, block }: SetInnerHTMLProps): void => {
6363
const chars = block.crdt.LinkedList.spread();
64+
// 캐럿 위치 정보 저장
65+
const selection = window.getSelection();
66+
const range = selection?.getRangeAt(0);
67+
let caretNode = range?.startContainer;
68+
const caretOffset = range?.startOffset;
69+
6470
if (chars.length === 0) {
65-
element.innerHTML = "";
71+
while (element.firstChild) {
72+
element.removeChild(element.firstChild);
73+
}
6674
return;
6775
}
6876

69-
// 각 위치별 모든 적용된 스타일을 추적
7077
const positionStyles: TextStyleState[] = chars.map((char) => {
7178
const styleSet = new Set<string>();
72-
73-
// 현재 문자의 스타일 수집
7479
char.style.forEach((style) => styleSet.add(TEXT_STYLES[style]));
75-
7680
return {
7781
styles: styleSet,
7882
color: char.color,
7983
backgroundColor: char.backgroundColor,
8084
};
8185
});
8286

83-
let html = "";
87+
const fragment = document.createDocumentFragment();
88+
let currentSpan: HTMLSpanElement | null = null;
8489
let currentState: TextStyleState = {
8590
styles: new Set<string>(),
8691
color: "black",
8792
backgroundColor: "transparent",
8893
};
89-
let spanOpen = false;
94+
95+
// 캐럿이 있던 노드의 텍스트 내용과 오프셋 저장
96+
// const caretNodeText = caretNode?.textContent || "";
9097

9198
chars.forEach((char, index) => {
9299
const targetState = positionStyles[index];
100+
const hasStyles =
101+
targetState.styles.size > 0 ||
102+
targetState.color !== "black" ||
103+
targetState.backgroundColor !== "transparent";
93104

94-
// 스타일, 색상, 배경색 변경 확인
95-
const styleChanged =
96-
!setsEqual(currentState.styles, targetState.styles) ||
97-
currentState.color !== targetState.color ||
98-
currentState.backgroundColor !== targetState.backgroundColor;
105+
if (hasStyles) {
106+
const styleChanged =
107+
!setsEqual(currentState.styles, targetState.styles) ||
108+
currentState.color !== targetState.color ||
109+
currentState.backgroundColor !== targetState.backgroundColor;
99110

100-
// 변경되었으면 현재 span 태그 닫기
101-
if (styleChanged && spanOpen) {
102-
html += "</span>";
103-
spanOpen = false;
104-
}
111+
if (styleChanged || !currentSpan) {
112+
currentSpan = document.createElement("span");
113+
currentSpan.className = getClassNames(targetState);
114+
currentSpan.style.whiteSpace = "pre";
115+
fragment.appendChild(currentSpan);
116+
currentState = targetState;
117+
}
105118

106-
// 새로운 스타일 조합으로 span 태그 열기
107-
if (styleChanged) {
108-
const className = getClassNames(targetState);
109-
html += `<span class="${className}" style="white-space: pre;">`;
110-
spanOpen = true;
119+
const textNode = document.createTextNode(sanitizeText(char.value));
120+
currentSpan.appendChild(textNode);
121+
} else {
122+
currentSpan = null;
123+
const textNode = document.createTextNode(sanitizeText(char.value));
124+
fragment.appendChild(textNode);
111125
}
126+
});
127+
128+
// DOM 업데이트를 위한 노드 비교 및 변경
129+
const existingNodes = Array.from(element.childNodes);
130+
const newNodes = Array.from(fragment.childNodes);
131+
let i = 0;
112132

113-
// 텍스트 추가
114-
html += sanitizeText(char.value);
133+
// 공통 길이만큼 업데이트 또는 재사용
134+
const minLength = Math.min(existingNodes.length, newNodes.length);
135+
for (; i < minLength; i++) {
136+
if (!nodesAreEqual(existingNodes[i], newNodes[i])) {
137+
if (caretNode === existingNodes[i]) {
138+
// 캐럿이 있던 노드가 교체되는 경우, 새 노드에서 동일한 텍스트 위치 찾기
139+
caretNode = newNodes[i];
140+
}
141+
element.replaceChild(newNodes[i], existingNodes[i]);
142+
}
143+
}
115144

116-
// 다음 문자로 넘어가기 전에 현재 상태 업데이트
117-
currentState = targetState;
145+
// 남은 새 노드 추가
146+
for (; i < newNodes.length; i++) {
147+
element.appendChild(newNodes[i]);
148+
}
118149

119-
// 마지막 문자이고 span이 열려있으면 닫기
120-
if (index === chars.length - 1 && spanOpen) {
121-
html += "</span>";
122-
spanOpen = false;
150+
// 남은 기존 노드 제거
151+
while (i < existingNodes.length) {
152+
if (caretNode === existingNodes[i]) {
153+
// 캐럿이 있던 노드가 제거되는 경우
154+
caretNode = undefined;
123155
}
124-
});
156+
element.removeChild(existingNodes[i]);
157+
i += 1;
158+
}
159+
160+
// 캐럿 위치 복원
161+
// if (caretNode && typeof caretOffset === "number" && selection) {
162+
// try {
163+
// // 새로운 노드에서 캐럿 위치 설정
164+
// const newRange = document.createRange();
165+
// const newOffset = Math.min(caretOffset, caretNode.textContent?.length || 0);
166+
// newRange.setStart(caretNode, newOffset);
167+
// newRange.collapse(true);
168+
// selection.removeAllRanges();
169+
// selection.addRange(newRange);
170+
// } catch (error) {
171+
// console.error("Error restoring caret position:", error);
172+
// }
173+
// }
174+
};
175+
176+
const nodesAreEqual = (node1: Node, node2: Node): boolean => {
177+
if (node1.nodeType !== node2.nodeType) return false;
125178

126-
// DOM 업데이트
127-
if (element.innerHTML !== html) {
128-
element.innerHTML = html;
179+
if (node1.nodeType === Node.TEXT_NODE) {
180+
return node1.textContent === node2.textContent;
129181
}
182+
183+
if (node1.nodeType === Node.ELEMENT_NODE) {
184+
const elem1 = node1 as HTMLElement;
185+
const elem2 = node2 as HTMLElement;
186+
return (
187+
elem1.tagName === elem2.tagName &&
188+
elem1.className === elem2.className &&
189+
elem1.getAttribute("style") === elem2.getAttribute("style") &&
190+
elem1.textContent === elem2.textContent
191+
);
192+
}
193+
194+
return false;
130195
};
131196

197+
// export const setInnerHTML = ({ element, block }: SetInnerHTMLProps): void => {
198+
// const chars = block.crdt.LinkedList.spread();
199+
// if (chars.length === 0) {
200+
// element.innerHTML = "";
201+
// return;
202+
// }
203+
204+
// // 각 위치별 모든 적용된 스타일을 추적
205+
// const positionStyles: TextStyleState[] = chars.map((char) => {
206+
// const styleSet = new Set<string>();
207+
208+
// // 현재 문자의 스타일 수집
209+
// char.style.forEach((style) => styleSet.add(TEXT_STYLES[style]));
210+
211+
// return {
212+
// styles: styleSet,
213+
// color: char.color,
214+
// backgroundColor: char.backgroundColor,
215+
// };
216+
// });
217+
218+
// let html = "";
219+
// let currentState: TextStyleState = {
220+
// styles: new Set<string>(),
221+
// color: "black",
222+
// backgroundColor: "transparent",
223+
// };
224+
// let spanOpen = false;
225+
226+
// chars.forEach((char, index) => {
227+
// const targetState = positionStyles[index];
228+
229+
// // 스타일, 색상, 배경색 변경 확인
230+
// const styleChanged =
231+
// !setsEqual(currentState.styles, targetState.styles) ||
232+
// currentState.color !== targetState.color ||
233+
// currentState.backgroundColor !== targetState.backgroundColor;
234+
235+
// // 변경되었으면 현재 span 태그 닫기
236+
// if (styleChanged && spanOpen) {
237+
// html += "</span>";
238+
// spanOpen = false;
239+
// }
240+
241+
// // 새로운 스타일 조합으로 span 태그 열기
242+
// if (styleChanged) {
243+
// const className = getClassNames(targetState);
244+
// html += `<span class="${className}" style="white-space: pre;">`;
245+
// spanOpen = true;
246+
// }
247+
248+
// // 텍스트 추가
249+
// html += sanitizeText(char.value);
250+
251+
// // 다음 문자로 넘어가기 전에 현재 상태 업데이트
252+
// currentState = targetState;
253+
254+
// // 마지막 문자이고 span이 열려있으면 닫기
255+
// if (index === chars.length - 1 && spanOpen) {
256+
// html += "</span>";
257+
// spanOpen = false;
258+
// }
259+
// });
260+
261+
// // DOM 업데이트
262+
// if (element.innerHTML !== html) {
263+
// element.innerHTML = html;
264+
// }
265+
// };
266+
132267
// Set 비교 헬퍼 함수
133268
const setsEqual = (a: Set<string>, b: Set<string>): boolean => {
134269
if (a.size !== b.size) return false;

0 commit comments

Comments
 (0)