지난번에 이어 Rich 한 에디터를 만들어 보겠습니다.
Toolbar 만들기
이번에는 글에 Bold, Italic, underline, strikethrough를 적용할 수 있는 툴바를 만드려 하는데,
해야 하는 작업은 다음과 같습니다.
- 툴바 노드 만들기
- 텍스트에 드래그했을 때 툴바 활성화, 드래그 해제 시 툴바 비활성 이벤트 추가
Toolbar 노드 만들기
툴바 노드를 만들 때 한 번만 만들어 놓고 불러오는 위치만 달라지기 때문에 setup에 호출해 줄 툴바를 만드는 함수 하나를 만듭니다.
const setToolbar = () => {
const toolbar = document.createElement("div");
toolbar.className = "editor__toolbar";
toolbar.innerHTML = `
<button class="editor__toolbar--button" id="boldBtn">
<span class="material-symbols-rounded">format_bold</span>
</button>
<button class="editor__toolbar--button" id="italicBtn">
<span class="material-symbols-rounded">format_italic</span>
</button>
<button class="editor__toolbar--button" id="underlineBtn">
<span class="material-symbols-rounded">format_underlined</span>
</button>
<button class="editor__toolbar--button" id="strikethroughBtn">
<span class="material-symbols-rounded">format_strikethrough</span>
</button>
`;
document.body.appendChild(toolbar);
document
.getElementById("boldBtn")
.addEventListener("click", () => formatText("strong"));
document
.getElementById("italicBtn")
.addEventListener("click", () => formatText("em"));
document
.getElementById("underlineBtn")
.addEventListener("click", () => formatText("u"));
document
.getElementById("strikethroughBtn")
.addEventListener("click", () => formatText("s"));
const formatText = (tagName) => {
const selection = window.getSelection();
if (!selection.isCollapsed) {
const range = selection.getRangeAt(0);
const selectedText = range.toString();
const newNode = document.createElement(tagName);
newNode.textContent = selectedText;
range.deleteContents();
range.insertNode(newNode);
newNode.focus();
}
};
return toolbar;
};
툴바 안의 버튼에 이벤트들을 각각 지정해 주고
formatText를 통해 드래그된 위치에 태그를 씌워 기존 위치에 삽입해 주는 방식으로 동작합니다.
생성한 함수는 다음과 같이 사용해 줍니다.
export default class EditorTotalContents extends Component {
setup() {
...
this.toolbar = setToolbar();
}
...
툴바 활성화, 비활성화 이벤트 추가
이후 드래그를 했을 때 툴바를 활성화하고 드래그 외의 노드를 클릭했을 때 비활성화하는 이벤트를 만들겠습니다.
this.addEvent("mouseup", ".editor__content", (e) => {
const selection = window.getSelection();
if (!selection.isCollapsed) {
const box = selection.getRangeAt(0).getBoundingClientRect();
const toolbarTop = box.top - 45;
const toolbarLeft = box.left;
this.toolbar.style.top = `${toolbarTop}px`;
this.toolbar.style.left = `${toolbarLeft}px`;
this.toolbar.style.display = "block";
} else {
this.toolbar.style.display = "none";
}
});
document.addEventListener("mousedown", (e) => {
const isClickInsideEditor = e.target.closest(".editor__content") !== null;
const isClickInsideToolbar = this.toolbar.contains(e.target);
if (!isClickInsideEditor && !isClickInsideToolbar) {
this.toolbar.style.display = "none";
}
});
해당 위치에 마우스를 드래그한 뒤 손을 떼었을 때 커서의 시작 위치와 끝 위치가 다르면 드래그되었다 판단해 toolbar를 활성화했고, 그렇지 않은 경우엔 비활성화시켰습니다.
mousedown 이벤트는 이전과 다르게 this.addEvent를 사용하지 않고 addEventListener를 직접 사용했는데
이는 제가 사용한 component 템플릿의 addEvent에서 현재 위치의 노드 기준으로 이벤트를 지정하기 때문입니다. 따라서 현재 위치보다 상위 위치에서도 이벤트를 적용하기 위해 이러한 방법을 사용했습니다.
해당 이벤트는 툴바나 툴바를 호출한 노드 외에 다른 부분을 클릭했을 때 툴바를 보이지 않게 합니다.
결과물은 다음과 같습니다.
문제 발생
기존의 스타일 제거 불가 및 중복 적용 불가
이전의 코드로는 기존에 적용된 스타일을 제거할 수 없고 스타일을 여러 개 적용할 수 없는 문제가 발생합니다.
이를 해결하기 위해 노션과 같은 방법으로 인라인 스타일을 적용한 span 태그를 이용해 스타일을 적용하는 방식으로 바꿔보았습니다.
이를 위해 formatText를 변경했습니다.
const formatText = (tagName) => {
const selection = window.getSelection();
if (!selection.isCollapsed) {
const range = selection.getRangeAt(0);
const selectedText = range.toString();
const startNode = range.startContainer;
const endNode = range.endContainer;
const alreadySpan = startNode.nodeName.toLowerCase() === "span";
const newNode = alreadySpan ? startNode : document.createElement("span");
switch (tagName) {
case "bold":
if (newNode.style.fontWeight === "bold") {
newNode.style.fontWeight = "";
} else {
newNode.style.fontWeight = "bold";
}
break;
case "italic":
if (newNode.style.fontStyle === "italic") {
newNode.style.fontStyle = "";
} else {
newNode.style.fontStyle = "italic";
}
break;
case "underline":
if (newNode.style.textDecorationLine === "underline") {
newNode.style.textDecorationLine = "";
} else if (
newNode.style.textDecorationLine === "underline line-through"
) {
newNode.style.textDecorationLine = "line-through";
} else {
if (newNode.style.textDecorationLine === "line-through") {
newNode.style.textDecorationLine = "underline line-through"; // 둘 다
} else {
newNode.style.textDecorationLine = "underline"; // 밑줄
}
}
break;
case "strikethrough":
if (newNode.style.textDecorationLine === "line-through") {
newNode.style.textDecorationLine = "";
} else if (
newNode.style.textDecorationLine === "underline line-through"
) {
newNode.style.textDecorationLine = "underline";
} else {
if (newNode.style.textDecorationLine === "underline") {
newNode.style.textDecorationLine = "underline line-through"; // 둘 다
} else {
newNode.style.textDecorationLine = "line-through"; // 취소선
}
}
break;
default:
break;
}
if (!alreadySpan) {
newNode.textContent = selectedText;
range.deleteContents(); // 선택된 텍스트 삭제
range.insertNode(newNode); // 새 노드 삽입
//기존에 선택되었던 드래그 범위가 재정의 되는 문제 해결
const selectionRange = document.createRange();
selectionRange.selectNodeContents(newNode); // 새 노드를 선택
selection.removeAllRanges(); // 기존 선택 범위 제거
selection.addRange(selectionRange); // 새 범위를 추가
}
newNode.focus();
}
};
const alreadySpan = startNode.nodeName.toLowerCase() === "span";
const newNode = alreadySpan ? startNode : document.createElement("span");
드래그가 시작된 노드가 span이라면 기존 노드를 사용하고, span이 아니라면 새로운 노드를 만들도록 했습니다.
switch (tagName) {
case "bold":
if (newNode.style.fontWeight === "bold") {
newNode.style.fontWeight = "";
} else {
newNode.style.fontWeight = "bold";
}
break;
case "italic":
if (newNode.style.fontStyle === "italic") {
newNode.style.fontStyle = "";
} else {
newNode.style.fontStyle = "italic";
}
break;
case "underline":
if (newNode.style.textDecorationLine === "underline") {
newNode.style.textDecorationLine = "";
} else if (
newNode.style.textDecorationLine === "underline line-through"
) {
newNode.style.textDecorationLine = "line-through";
} else {
if (newNode.style.textDecorationLine === "line-through") {
newNode.style.textDecorationLine = "underline line-through"; // 둘 다
} else {
newNode.style.textDecorationLine = "underline"; // 밑줄
}
}
break;
case "strikethrough":
if (newNode.style.textDecorationLine === "line-through") {
newNode.style.textDecorationLine = "";
} else if (
newNode.style.textDecorationLine === "underline line-through"
) {
newNode.style.textDecorationLine = "underline";
} else {
if (newNode.style.textDecorationLine === "underline") {
newNode.style.textDecorationLine = "underline line-through"; // 둘 다
} else {
newNode.style.textDecorationLine = "line-through"; // 취소선
}
}
break;
default:
break;
}
만약 스타일이 적용이 되어있다면 제거하고, 적용이 되어있지 않다면 적용하도록 속성을 지정해 줬습니다.
line-through와 strikethrough의 경우 textDecorationLine으로 공통되었기 때문에 다음과 같이 지정했습니다.
if (!alreadySpan) {
newNode.textContent = selectedText;
range.deleteContents(); // 선택된 텍스트 삭제
range.insertNode(newNode); // 새 노드 삽입
//기존에 선택되었던 드래그 범위가 재정의 되는 문제 해결
const selectionRange = document.createRange();
selectionRange.selectNodeContents(newNode); // 새 노드를 선택
selection.removeAllRanges(); // 기존 선택 범위 제거
selection.addRange(selectionRange); // 새 범위를 추가
}
newNode.focus();
새로운 span을 만들어 적용할 때 선택된 텍스트를 삭제하고 삽입되는 부분은 동일합니다.
기존에 선택되었던 드래그 범위가 재정의 되는 문제 해결 주석이 달린 코드가 핵심인데, 새 노드가 삽입되면 드래그를 새로 하지 않은 채로 그대로 툴바를 이용해도 드래그 범위가 재정의 되는 문제 startContainer와 endContainer가 클릭할 때마다 변경되는 문제를 해결할 수 있었습니다.
이 방식으로 저는 기존의 스타일 제거 불가 및 중복 적용 불가 문제를 해결할 수 있었습니다.
'프로젝트 > Vanilla JS 문서편집기' 카테고리의 다른 글
[Vanilla JS 문서편집기 설명 및 개선] 9. Jest를 이용해 테스트 코드 적용하기 (2) | 2024.09.15 |
---|---|
[Vanilla JS 문서편집기 설명 및 개선] 7. Rich한 에디터 만들기(1) (0) | 2024.09.06 |
[Vanilla JS 문서편집기 설명 및 개선] 6. 중앙 집중식 상태 관리 적용하기 (1) | 2024.09.04 |
[Vanilla JS 문서편집기 설명 및 개선] 5. VirtualDOM처럼 렌더링하기 (1) | 2024.08.29 |
[Vanilla JS 문서편집기 설명 및 개선] 4. 컴포넌트 템플릿 만들기 (0) | 2024.08.29 |