지난 포스팅 - 볼드, 이탤릭 등 스타일 변경 기능 구현 1편 -에 이어서 작성하는 글입니다.
1편에서처럼 데이터 스키마를 정한 이후
1) 서버에서 받아온 데이터를 화면에 보여주고,
2) 사용자가 입력한 스타일 정보가 포함된 텍스트를 서버에 보낼 때 정해진 양식으로 변환하는
두 가지 함수가 먼저 필요했다.
{
trg_text: "아버지가 방에 들어가신다.",
trg_text_styles: [
{ range: [0, 3], styles: ['bold', 'italic'] },
],
}
enum StyleToTagMap {
bold = 'b',
italic = 'i',
underline = 'u',
subscript = 'sub',
superscript = 'sup',
}
type StyleType = keyof typeof StyleToTagMap;
type TagType = `${StyleToTagMap}`; // 'b' | 'i' | 'u' | 'sub' | 'sup'
const TagToStyleMap: Record<TagType, StyleType> = Object.fromEntries(
Object.entries(StyleToTagMap).map(([key, value]) => [value, key]),
) as Record<TagType, StyleType>;
export const applyTextStyles = (text: string, styles: TargetTextStyle[]) => {
// 1. 텍스트를 각 문자 단위로 나누고, 각 문자에 적용된 스타일을 저장할 배열 생성
const textArray = text.split('');
const styleMap: { [index: number]: TagType[] } = {};
// 2. 스타일을 하나씩 순회하면서, 해당 범위에 스타일을 누적
styles.forEach(({ range: [start, end], styles: styleTypes }) => {
for (let i = start; i < end; i++) {
styleMap[i] = [...new Set([...(styleMap[i] || []), ...styleTypes.map((styleType) => StyleToTagMap[styleType])])];
}
});
let formattedText = '';
let openTags: TagType[] = []; // 이전 문자에 적용된 스타일의 리스트
// 3. 각 문자를 순회하면서 스타일을 적용
textArray.forEach((char, index) => {
const currentStyles = styleMap[index] || []; // ['b', 'i'] 등 태그 이름 리스트가 들어옴
// 스타일이 달라질 때마다 스타일을 닫고 열기: 중첩 구조에 대응 가능
if (currentStyles.length !== openTags.length || !currentStyles.every((tag, idx) => tag === openTags[idx])) {
// 기존의 열려있던 태그를 닫기
openTags.reverse().forEach((tag) => {
formattedText += `</${tag}>`;
});
// 새로운 스타일을 열기
currentStyles.forEach((tag) => {
formattedText += `<${tag}>`;
});
}
// 현재 문자를 추가
formattedText += char;
// 현재 적용된 스타일로 오픈 태그 업데이트
openTags = currentStyles;
});
// 4. 끝나지 않은 태그 닫기
openTags.reverse().forEach((tag) => {
formattedText += `</${tag}>`;
});
return formattedText;
};
export const extractTextStylesFromElement = (targetCell: HTMLElement): TargetTextStyle[] => {
let accumulatedLength = 0;
const textStyles: TargetTextStyle[] = [];
const openStylesStack: { tag: TagType; startOffset: number }[] = [];
// 각 인덱스마다 적용된 스타일을 저장할 스타일 맵
const styleMap: { [index: number]: StyleType[] } = {};
// 텍스트와 스타일 태그를 순차적으로 탐색하는 함수
const traverseNodes = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || '';
// 현재 적용된 스타일을 해당 텍스트 범위에 매핑
for (let i = 0; i < text.length; i++) {
styleMap[accumulatedLength + i] = openStylesStack.map((style) => TagToStyleMap[style.tag]);
}
accumulatedLength += text.length;
} else if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement;
const tagName = element.tagName.toLowerCase() as TagType;
const currentStyle = TagToStyleMap[tagName];
// 스타일 태그가 발견된 경우 (b, i, u, sub, sup)
if (currentStyle) {
openStylesStack.push({ tag: tagName, startOffset: accumulatedLength });
}
// 자식 노드 탐색
element.childNodes.forEach(traverseNodes);
// 스타일 태그를 닫는 경우
if (currentStyle) {
openStylesStack.pop();
}
}
};
// 탐색 시작
targetCell.childNodes.forEach(traverseNodes);
// 2. styleMap을 사용해 범위와 스타일을 추출
let currentStyles: StyleType[] = [];
let currentRangeStart: number | null = null;
for (let i = 0; i <= accumulatedLength; i++) {
const stylesAtCurrentIndex = styleMap[i] || [];
const isStylesChanged =
currentStyles.length !== stylesAtCurrentIndex.length ||
currentStyles.some((style, idx) => style !== stylesAtCurrentIndex[idx]);
if (isStylesChanged) {
// 현재 스타일이 변할 때 기존 스타일을 저장
if (currentStyles.length > 0 && currentRangeStart !== null) {
textStyles.push({
range: [currentRangeStart, i] as [number, number],
styles: currentStyles,
});
}
// 새로운 스타일 구간 시작
currentStyles = stylesAtCurrentIndex;
currentRangeStart = i;
}
}
// 마지막 스타일 구간을 처리
if (currentStyles.length > 0 && currentRangeStart !== null) {
textStyles.push({
range: [currentRangeStart, accumulatedLength] as [number, number],
styles: currentStyles,
});
}
return textStyles;
};
DOM 구조를 거꾸로 데이터 스키마에 맞게 변경하는 extractTextStylesFromElement 함수를 작성할 때 자식 node를 재귀적으로 순회하면서 탐색할 수 밖에 없다고 생각했는데 이외에 다른 방법이 있다면 무엇인지 알고 싶다.
요구사항 중에 ctrl + b 등 단축키와 스타일 변경 버튼 두 가지로 조작 가능해야 한다는 내용이 있었는데, contenteditable 요소에서 ctrl (Mac의 경우 cmd) + b 등 기본 단축키에는 아무것도 하지 않아도 <b>, <i> 태그 등이 사용자가 선택한 텍스트 영역을 감싸면서 화면 상에서는 이미 볼드, 이탤릭 등이 적용된다. 스타일 변경 버튼을 클릭할 때도 동일한 동작을 실행하기 위해 처음에는 Web API 인 document.execCommand()를 붙였다. mdn 문서 처음부터 deprecated 라고 써져 있었지만... 일단(?) 빠르게 구현하기 위해서 그대로 사용했다. 하지만 치명적인 단점이 곧바로 발견되었는데 bold, italic, underline의 경우 사용자가 선택한 영역을 볼드로 바꾼 후 다시 같은 영역에 볼드 요청을 할 때 볼드가 취소되는 토글이 정상적으로 동작하는 반면, 위첨자(superscript)와 아래첨자(subscript)는 서식이 한 번 적용된 이후에 취소가 되지 않았다.
switch (styleType: StyleType) {
case 'bold':
case 'italic':
case 'underline':
document.execCommand(styleType);
break;
case 'subscript':
toggleTag('SUB', styleType);
break;
case 'superscript':
toggleTag('SUP', styleType);
break;
default:
console.warn(`Unsupported style type: ${styleType}`);
}
그리하여 이런 괴이한 switch문이 탄생했다.. toggleTag 함수는 스타일 적용 여부를 판단해서 스타일이 이미 적용되어 있다면 해당 스타일을 제거해주고, 스타일이 적용되지 않다면 또다시 똑같이 document.execCommand() 를 호출하는 함수로 작성했다.
하지만 QA 테스트 단계에서 치명적인 결함이 발견되었다. "동일 서식을 넓은 범위에 중복 적용할 때에 동일한 동작이 보장되지 않는다"는 것이었다. 조금 더 구체적으로 설명하면 "아버지가 방에 들어가신다."처럼 이미 일부 범위에 볼드가 적용된 경우 사용자가 전체 문장을 선택하고 볼드 버튼 또는 ctrl + b 단축키를 입력하면 기대하는 결과는 "아버지가 방에 들어가신다."처럼 전체 문장이 볼드 처리가 되는 것이다. 이는 문장 내에서 일부 범위가 문장의 맨 앞이든지 중간이든지 제일 끝이든지 상관없이 사용자가 기대하는 동작이다. 하지만 QA 테스트에서 윈도우 크롬 브라우저 기준 밑줄(underline)의 경우 문장 앞부분 일부에 밑줄이 적용되어 있는 상태에서 뒷부분으로 더 넓은 범위를 선택 후 밑줄을 재적용했을 때 더 넓은 범위에 밑줄이 적용되는 것이 아닌, 기존 밑줄이 적용되어 있는 문장 앞부분의 밑줄이 해제되는 결과가 나왔다. 그리고 Mac 크롬 브라우저에서는 볼드, 이탤릭, 밑줄의 경우가 모두 동일하게 문장 앞부분에 서식이 적용되어 있고, 더 넓은 범위에 동일 서식을 재요청할 때 서식이 해제되는 상황이었다.
결국 document.execCommand() Web API를 사용할 수 없다는 결론을 내렸고 자체 구현에 들어갔다. 이미 최초 렌더링시 서버에서 가져온 TargetTextStyle 배열을 가지고 서식을 적용하는 applyTextStyles 함수가 구현이 되어 있으니, 사용자가 ctrl + b 등 단축키 입력을 하거나 스타일 변경 버튼을 클릭할 때에도 데이터를 해당 스키마에 맞게 변경해주자는 게 자체 구현 함수의 전략이었다.
interface TargetTextStyle {
range: [number, number];
styles: StyleType[];
}
TargetTextStyle 데이터 타입은 위와 같이 range 정보를 포함하고 있는데, 이는 plain text 상태의 문장의 index 정보다. 따라서 다음의 두 단계에 걸쳐 스타일 정보를 업데이트해야 했다.
1) getSelectionIndices 함수: 사용자가 서식을 적용/해제하기 원하는 selection & range 정보를 바탕으로 해당 문장 내의 startIndex와 endIndex를 구해야 한다.
"아버지가 방에 들어가신다."라는 문장에서 사용자가 '아버지'를 선택했다면 startIndex는 0, endIndex는 3이다.
2) calculateNewStyles 함수: 위의 index 정보를 바탕으로 bold, italic 등 사용자가 지정한 서식을 적용/해제한 결과가 적용된 TargetTextStyle 을 반환하는 계산을 수행한다.
let newStyles: TargetTextStyle[] = [];
const currentStyles = segment?.trg_text_styles ?? [];
const indices = getSelectionIndices(segment.id);
if (indices) {
const [startIndex, endIndex] = indices;
const newStyleInfo = { startIndex, endIndex, styleType };
newStyles = calculateNewStyles(newStyleInfo, currentStyles);
} else {
return; // 사용자가 선택한 텍스트 영역이 없으면 early return
}
각 함수의 구현은 다음과 같다.
/**
* 루트 노드부터 시작하여 선택된 노드(targetNode)까지의 텍스트 길이를 누적하여 오프셋 계산
*
* @param {Node} rootNode - 탐색을 시작할 루트 노드
* @param {Node} targetNode - 오프셋을 계산할 타겟 노드
* @param {number} offsetInNode - 선택 영역의 시작 또는 끝에서의 오프셋 (커서 위치)
* @returns {number} 루트 노드부터 타겟 노드까지의 총 오프셋
*/
function _getOffset(rootNode: Node, targetNode: Node, offsetInNode: number): number {
let totalOffset = 0;
let found = false;
function traverse(currentNode: Node): boolean {
if (currentNode === targetNode) {
if (currentNode.nodeType === Node.TEXT_NODE) {
totalOffset += offsetInNode;
}
found = true;
return true;
}
if (currentNode.nodeType === Node.TEXT_NODE) {
if (!found) {
totalOffset += currentNode.textContent?.length || 0;
}
} else {
// currentNode가 element node인 경우 자식 노드를 순회하며 재귀적 탐색
for (let child = currentNode.firstChild; child; child = child.nextSibling) {
if (traverse(child)) return true;
}
}
return false;
}
traverse(rootNode);
if (!found) {
console.warn('targetNode not found within the root node.');
}
return totalOffset;
}
function getSelectionIndices(segmentId: number): [number, number] | null {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
if (range.collapsed) return null;
const rootNode = getTargetCellById(segmentId);
if (!rootNode) {
console.error('Cannot find rootNode with id:', segmentId);
return null;
}
const startIndex = _getOffset(rootNode, range.startContainer, range.startOffset);
const endIndex = _getOffset(rootNode, range.endContainer, range.endOffset);
return [startIndex, endIndex];
}
function _arraysEqual<T>(a: T[], b: T[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index]);
}
function _updateStylesInRange(
styleMap: { [index: number]: Set<StyleType> },
startIndex: number,
endIndex: number,
styleType: StyleType,
action: 'add' | 'remove',
) {
for (let i = startIndex; i < endIndex; i++) {
const styles = styleMap[i] || new Set<StyleType>();
switch (action) {
case 'add':
styles.add(styleType);
styleMap[i] = styles;
break;
case 'remove':
styles.delete(styleType);
if (styles.size > 0) {
styleMap[i] = styles;
} else {
delete styleMap[i];
}
break;
default:
throw new Error(`Invalid action "${action}" in _updateStylesInRange function.`);
}
}
}
function _convertStyleMapToArray(styleMap: { [index: number]: Set<StyleType> }): TargetTextStyle[] {
const newTargetTextStyles: TargetTextStyle[] = []; // 최종 반환하게 될 배열
let currentStyles: StyleType[] | null = null;
let rangeStart: number | null = null;
const indices = Object.keys(styleMap)
.map(Number)
.sort((a, b) => a - b);
let i = indices.length > 0 ? indices[0] : 0;
const maxIndex = indices.length > 0 ? indices[indices.length - 1] : 0;
while (i <= maxIndex) {
const stylesSet = styleMap[i];
const styles = stylesSet ? Array.from(stylesSet).sort() : undefined;
if (styles && (!currentStyles || !_arraysEqual(styles, currentStyles))) {
// 새로운 스타일 아이템 시작
if (currentStyles && rangeStart !== null) {
// 이전에 진행 중인 스타일 아이템이 있다면 완료하여 추가
newTargetTextStyles.push({
range: [rangeStart, i],
styles: currentStyles,
});
}
currentStyles = styles;
rangeStart = i;
} else if (!styles && currentStyles) {
// 스타일 아이템 종료
// 진행 중인 스타일 아이템을 완료하여 추가
newTargetTextStyles.push({
range: [rangeStart!, i],
styles: currentStyles,
});
// currentStyles와 rangeStart를 초기화
currentStyles = null;
rangeStart = null;
}
i++;
}
// 마지막 아이템 추가
if (currentStyles && rangeStart !== null) {
newTargetTextStyles.push({
range: [rangeStart, i],
styles: currentStyles,
});
}
return newTargetTextStyles;
}
function calculateNewStyles(
newStyleInfo: { startIndex: number; endIndex: number; styleType: StyleType },
existingStyles: TargetTextStyle[],
): TargetTextStyle[] {
const { startIndex, endIndex, styleType } = newStyleInfo;
// 1. 스타일 맵 생성 (중복 방지를 위해 Set 사용)
const styleMap: { [index: number]: Set<StyleType> } = {};
for (const item of existingStyles) {
for (let i = item.range[0]; i < item.range[1]; i++) {
if (styleMap[i]) {
for (const style of item.styles) {
styleMap[i].add(style);
}
} else {
styleMap[i] = new Set<StyleType>(item.styles);
}
}
}
// 2. 선택된 범위에 스타일이 완전히 다 적용되어 있는지 확인
let isFullyStyled = true;
for (let i = startIndex; i < endIndex; i++) {
const styles = styleMap[i] || new Set<StyleType>();
if (!styles.has(styleType)) {
isFullyStyled = false;
break;
}
}
// 3. 선택 범위에 스타일 적용 또는 제거
const action: 'add' | 'remove' = isFullyStyled ? 'remove' : 'add';
_updateStylesInRange(styleMap, startIndex, endIndex, styleType, action);
// 4. 맵을 배열로 변환
const newTargetTextStyles = _convertStyleMapToArray(styleMap);
return newTargetTextStyles;
}
이렇게 해서 newStyles 정보를 계산한 다음 기존 스타일 정보와 달라졌다면 1) 실행취소/재실행 기능을 위해 스냅샷을 저장하고 2) 클라이언트 ui 데이터 업데이트 및 3) 서버에 데이터 변경 요청을 하는 일련의 과정을 수행한다.
이외에도 사용자가 텍스트 변경을 할 때마다 클라이언트 ui 상태가 업데이트 될 수 있으므로 커서 위치 및 selection & range 저장했다 복원해주는 것도 까다로운 부분이었지만 여기에서 다루지 않기로 한다. 이미 너무나 방대한 코드가 들어갔다 ㅋㅋㅋ
이번 서식 변경 기능을 구현하면서 deprecated 라고 문서에 명시된 web api를 가져다 쓰려는 것이 얼마나 무모한 행위였는지 체감했다. 그리고 OS나 브라우저 환경이 다를 때 동일 동작을 보장하지 못하는 케이스를 혹독하게 경험한 것 같다. 한 문장, 한 문장씩 수정해 나가는 툴이기 때문에 문장 단위의 텍스트가 아주 길지 않기에 재귀 함수가 마구 돌아가고, DOM tree를 직접 제어하는 로직이어도 체감 성능이 아직까지는 크게 떨어지지 않는다.
다만 시스템이 확장되는 경우를 고려하면 개선의 여지가 아주아주 많다고 할 수 있다. 웹 컴포넌트나 DocumentFragment 등의 개념을 적용하여 개선할 여지가 있을지 미래의 나 녀석이 알아갈 기회가 있기를 희망해 본다. QA 빠꾸(?) 먹고 위와 같이 수정하여 다시 QA 테스트 통과한 다음 운영 서버에 올린 후, 처음 사용자 요청 로그가 들어온 날 너무 기뻐서 팀 슬랙에 메시지도 남겼다는 후문 ㅋㅋㅋ
📚 참고 자료
'배워서 남 주자' 카테고리의 다른 글
feature flags를 도입하기까지 (0) | 2024.12.31 |
---|---|
볼드, 이탤릭 등 스타일 변경 기능 구현 (1) (0) | 2024.10.20 |
PR에 빌드 에러 발생시 github-actions bot이 자동으로 코멘트 달게 하기 (0) | 2024.10.03 |
VS Code에서 파일 유형에 따라 default formatter를 설정하는 방법 (0) | 2024.08.25 |
부모의 상태에 따라 스타일링을 할 수 있는 Tailwind css의 `group` modifier (0) | 2024.08.24 |