김서버의 프론트엔드 일기
WYSIWYG 에디터 개발 - 1 (Selection으로 폰트 적용하기) 본문
개요
평소에 에디터에 관심이 많아서 이미지 에디터를 fabric.js 로도 만들어 보았었는데, 가장 대중적이고 많이 쓰는 WISIWYG 에디터도 한번 직접 만들고자 해서 만들었었습니다.
WYSIWYG란?
What You See Is What You Get의 줄임말로 직역하자면 "보는 그대로 얻어 낸다". 라는 뜻을 가지고 있는 에디터입니다.
보통 어떤 게시물을 써야 하는 기능을 넣을 때, 글 뿐만이 아니라 이미지와 동영상도 게시물에 같이 녹여져 있어야 할 때, 사용이 되는 에디터입니다.
유명한 오픈소스로는 Quill.js, frolara editor, summernote가 있겠습니다.
도전과제
사실 WYSIWYG는 document.execCommand 라는 함수를 사용하면 간단하게 구현할 수 있으나, 이 함수의 경우에는 웹 표준에서 제거가 되었기 때문에, 이를 사용하지 않고 구현하는 것이 도전과제 였습니다.
구현하고자 하는 기능은 다음과 같습니다.
- 폰트 사이즈를 사용자로부터 직접 입력 받아서 설정 할 수 있는 기능
- 폰트 컬러를 사용자로부터 직접 입력 받아서 설정 할 수 있는 기능
- 폰트 사이즈나 컬러를 적용 할 경우에 범위에 있는 모든 텍스트들에 스타일을 넣을 수 있는 기능
- 이미지를 파일 혹은 URL로 입력 받을 수 있는 기능
- 이미지의 사이즈를 조절 할 수 있는 기능
사용API
- Selection API, Range API
구현방식
- selectionchange 이벤트 발생시에 범위를 상태로 저장.
- 폰트 사이즈나 컬러를 적용할 때, 범위의 리스트에 전부 적용.
- 적용하면서 하위에 적용하는 스타일의 키값과 동일한 것들이 있으면 삭제
- 삭제 후에 적용 되어있는 스타일이 없을 경우에 끌어 올림
document.addEventListener("selectionchange", (e) => {
this.selection = document.getSelection();
this.type = this.selection.type;
this.range = this.selection.getRangeAt(0);
this.anchorNode = this.selection.anchorNode;
this.focusNode = this.selection.focusNode;
if (this.selection.type === "Range") {
this.setRangeNode();
}
this.setState({});
});
setRangeNode() {
let flag = false;
const board = this.parent.querySelector(".board");
this.rangeNodes = [];
board.childNodes.forEach((child) => {
if (flag) {
this.rangeNodes.push(child);
}
if (hasContains(child, this.selection.anchorNode)) {
if (!flag) this.rangeNodes.push(child);
flag = !flag;
}
if (hasContains(child, this.selection.focusNode)) {
if (!flag) this.rangeNodes.push(child);
flag = !flag;
}
});
}
이벤트 발생할 때, 주로 사용 되는 값을 클래스에 상태로 저장 후에, type이 Range일 경우 현재 범위를 라인별로 저장을 합니다.
fontSet(styles: Record<string, string>) {
if (this.type === "Range") {
this.rangeEventListener(styles);
}
if (this.type === "Caret") {
this.caretEventListener(styles);
}
}
Selection에서의 type은 크게 범위를 드래그로 지정한 Range와 드래그 하지 않은 상태인 Caret이 있습니다.
Caret타입인 경우에는 styles를 적용한 Span을 삽입하고 포커스를 맞추도록 하고, Range일 경우 해당 범위에 스타일을 적용한 후에 하위 children들의 같은 스타일 적용을 전부 해제합니다.
rangeEventListener(styles: Record<string, string>) {
if (this.anchorNode !== this.focusNode) {
this.rangeNodes.map((node, index) => {
elementNodeStyleChange(node as HTMLDivElement, index);
});
} else {
this.oneTextNodeStyleChange(styles);
}
}
먼저 Range일 경우에는 크게 2가지로 나누었습니다.
시작노드와 끝의 노드가 같은 경우는 간단하게 span을 만들어서 스타일을 적용하고 상위 노드에 붙이는 것으로 구현이 완료가 되었습니다.
시작노드와 끝의 노드가 다른 경우에는 다시 또, 첫번째와 마지막 노드와 그렇지 않은 가운데 노드들로 구분하여 구현 했습니다.
let flag = false;
const changeNodesForFirstOrLast = (node: Node, index: number) => {
if (hasContains(node, this.range.startContainer)) {
flag = true;
setRangeContainerStyle(this.range, node, styles, true);
return;
}
if (hasContains(node, this.range.endContainer)) {
setRangeContainerStyle(this.range, node, styles, false);
flag = false;
return;
}
if (flag) {
if (node.nodeName === "#text") {
setStyleFullText(node, styles);
} else {
setStyle(node as HTMLSpanElement, styles);
node.childNodes.forEach((child) => {
if (child.nodeName !== "#text") {
findSpanStyleRemove(child as HTMLSpanElement, styles);
}
});
}
}
};
const elementNodeStyleChange = (node: HTMLDivElement, index: number) => {
if (index === 0 || index === this.rangeNodes.length - 1) {
const childNodes: Node[] = [];
node.childNodes.forEach((child) => {
childNodes.push(child);
});
childNodes.map((childNode, index) => {
changeNodesForFirstOrLast(childNode, index);
});
} else {
const span = document.createElement("span");
setStyle(span, styles);
node.childNodes.forEach((child) => span.appendChild(child));
node.innerHTML = "";
node.appendChild(span);
const spanChilds: HTMLSpanElement[] = [];
span.childNodes.forEach((child) => {
if (child.nodeName === "SPAN") spanChilds.push(child as HTMLSpanElement);
});
spanChilds.map((span) => findSpanStyleRemove(span, styles));
}
};
양 끝 줄의 경우에는 현재 startContainer endContainer를 기점으로 적용 되고 안되는 것이 중요하므로, 순차적으로 children들에 대하여 DFS 알고리즘으로 flag를 설정 할 수 있도록 합니다. startContainer는 왼쪽 부터 endContainer는 오른쪽 부터 탐색을 시작합니다.
가운데의 노드는 바로 child를 만들고 style을 적용한 후에 하위의 children들을 DFS알고리즘으로 탐색해서 아래에서부터 위로 지워가며 스타일이 없을 경우 끌어올리는 방식으로 구현을 했습니다.
결론 및 느낀점
Selection API를 이용해서 범위를 가지고 핸들링을 해서 스타일을 적용하는 정도로 사용을 했었지만 이걸 잘 이용하면 유저가 지금 어디에 집중을 하고 있는지에 대해 알 수 있는 API로도 유용하다는 생각이 들었습니다.
'공부하는거 > Web' 카테고리의 다른 글
MediaRecorder와 FileSystem으로 실시간 녹화 저장 프로그램 만들기 (0) | 2022.03.06 |
---|---|
Service Worker란? (with PWA) (0) | 2022.02.07 |
HTML Form 그리고 web-form-helper (0) | 2022.01.29 |