Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion client/src/features/editor/components/block/Block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,11 +266,32 @@ export const Block: React.FC<BlockProps> = 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 규칙을 수정해야 할까?
Expand Down
215 changes: 172 additions & 43 deletions client/src/features/editor/utils/domSyncUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

// 현재 문자의 스타일 수집
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<string>(),
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 += "</span>";
spanOpen = false;
}

// 새로운 스타일 조합으로 span 태그 열기
// 스타일이 변경되었다면 현재까지의 텍스트를 처리
if (styleChanged) {
const className = getClassNames(targetState);
html += `<span class="${className}" style="white-space: pre;">`;
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 += "</span>";
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<string>();

// // 현재 문자의 스타일 수집
// 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<string>(),
// 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 += "</span>";
// spanOpen = false;
// }

// // 새로운 스타일 조합으로 span 태그 열기
// if (styleChanged) {
// const className = getClassNames(targetState);
// html += `<span class="${className}" style="white-space: pre;">`;
// spanOpen = true;
// }

// // 텍스트 추가
// html += sanitizeText(char.value);

// // 다음 문자로 넘어가기 전에 현재 상태 업데이트
// currentState = targetState;

// // 마지막 문자이고 span이 열려있으면 닫기
// if (index === chars.length - 1 && spanOpen) {
// html += "</span>";
// spanOpen = false;
// }
// });

// // DOM 업데이트
// if (element.innerHTML !== html) {
// element.innerHTML = html;
// }
// };

// Set 비교 헬퍼 함수
const setsEqual = (a: Set<string>, b: Set<string>): boolean => {
if (a.size !== b.size) return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ export const pageControlContainer = css({
gap: "sm",
_hover: {
"& svg": {
transform: "scale(1)", // 추가 효과
opacity: 1,
},
},
Expand Down
Loading