우리가 흔히 사용하는 글 작성 Editor 를 한번 만들어보고 싶어졌다
그리고...난 너무 후회했다, 시작을 하지 말걸....
요즘 jQuery 를 사용하지 않는 곳이 없다지만
공부 겸, 어디서든 사용할 수 있도록 jQuery 를 배제하고 순수 Javascript 로만 코드를 만들고 싶었다
개발자로 일해오면서 거진 대부분은 jQuery 만 사용해 왔고
순수 Javascript 로는 getElementById 또는 querySelector 같은 단순한 것 만들 사용 해봐서
개발 방향 및 방식은 내 생각대로 하고, 코드 구현은 ChatGPT 로 하였다
(개인적으로 ChatGPT 로 개발하는 걸 좋아하지 않는다)
jQuery 로 했으면 이렇게까지 고생하지 않고 ChatGPT 없이 만들었을 거 같은데...
만들다 지치다 못해 손을 놓고 싶어졌다
현재 이 코드는 100% 완벽하게 정상동작 하는 코드는 아니며,
웬만하면 무료 editor 를 사용하길 바란다
참고
- editor 를 만들기 위해서는 편집을 자유자대로 할 수 있게 contenteditable="true" 을 사용해야 한다
- execCommand 는 선택된 영역을 변경해 주는 편집기능 함수인데 이제는 지원되지 않는 함수이다
- 현재 코드에서는 execCommand 와 대체 함수인 document.createRange() 와 window.getSelection() 을 이용하여 구현하였다
Formats(형식)
h1~6 까지의 형식을 변경한다
1. 형식을 선택하면 editor 에 마우스 커서가 생기고 변경된 형식으로 글을 작성할 수 있다
2. 작성된 글에 커서를 가져가고 형식을 변경하면 문장 전체가 변경된다
3. 형식을 변경하면 Bold 스타일이 적용되는데 이건 html 에서 인식하는 style 이다
Size(글자 크기)
글자 font-size 를 변경한다
1. 글자 크기를 선택하면 editor 에 마우스 커서가 생기고 변경된 글자 크기로 글을 작성할 수 있다
2. 변경하고 싶은 문장을 드래그 하고 변경하면 글자 크기가 변경된다
Style(글자 스타일)
문장의 Style 을 변경한다
1. Style 을 클릭하면 editor 에 커서가 생기고 변경된 Style 로 글을 작성할 수 있다
2. 글 작성 중에 스타일을 변경하면 커서를 기준으로 작성하는 글에 적용된다
3. 커서를 기준으로 현재 적용되어 있는 Style 에 따라 Style 버튼에 active 된다
4. Enter 줄 바꿈 또는 Format 의 형식에 따라 Style 이 가끔 꼬이는 거 같다
IMG(이미지 첨부 및 사이즈 조절)
이미지를 하나 또는 여러 개를 업로드 할 수 있다
1. 이미지를 선택하면 오른쪽 하단에 이미지를 조절할 수 있는 Handle 이 생성된다
2. 이미지가 여러 개인 경우 이미지끼리는 줄 바꿈이 가능하다
3. 이미지 뒤에 문장이 있는 경우 문장 끝에서 줄 바꿈에 문제가 있다
4. 이미지 뒤에 문장에서 문장 중간에 줄 바꿈은 가능하다
정렬
문장을 정렬할 수 있다
1. 정렬 버튼을 클릭하면 editor 에 커서가 생기고 변경된 정렬 Style 로 글을 작성할 수 있다
2. 정렬 Style 이 적용된 상태에서 줄 바꿈 시 정렬 Style 이 적용되지 않는다
Table(테이블 표 추가)
Table 표를 생성할 수 있다
1. Table 내에서 모든 Style 이 적용 가능하다
2. Table 생성 후 Table 뒤에 커서를 두고 줄 바꿈을 하면 Table 이 사라지는 문제가 있다
전체 코드
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
div#editor {
padding: 10px 10px;
border: 1px solid #bdbdbd;
border-radius: 4px;
overflow-y: auto;
width: 100%;
height: 100%;
}
div#editor:focus {
outline: none;
}
.editorParents {
border: 1px solid #dadada;
border-radius: 4px;
margin-left: 10px;
margin-right: 10px;
width: auto;
text-align: center;
padding-left: 5px;
padding-right: 5px;
position: relative;
display: inline-block;
}
.editorChild {
margin-bottom: 3px;
position: relative;
}
#editor img {
display: inline; /* 이미지를 inline 요소처럼 배치 */
vertical-align: middle; /* 글과 정렬을 맞춤 */
max-width: 100%; /* 너무 크지 않도록 */
}
#imgTag {
display: none;
}
button {
border-color: #bdbdbd;
cursor: pointer;
border: 1px solid;
background-color: inherit;
color: #bdbdbd;
}
button.active {
color : #4592ff;
}
div.active {
background-color : #bdbdbd !important;
}
<!-- 드랍다운-->
.dropdown{
position : relative;
display : inline-block;
}
.dropBtn{
border : 1px solid;
border-radius : 4px;
background-color: inherit;
margin-right: 10px;
color: #bdbdbd;
text-align: left;
cursor : pointer;
font-size : 12px;
position : relative;
}
.dropdown-content{
display : none;
font-weight: 400;
background-color: #fcfcfc;
border-radius: 8px;
height : 160px;
overflow : scroll;
box-shadow: 0px 0px 10px 3px rgba(190, 190, 190, 0.6);
position : absolute;
z-index: 10000 !important; /* 무조건 이미지보다 위로 */
}
.dropdown-content::-webkit-scrollbar{
width : 5px;
height : 10px;
}
.dropdown-content::-webkit-scrollbar-thumb{
border-radius : 2px;
background-color :rgb(194, 194, 194)
}
.dropdown-content div{
display : block;
text-decoration : none;
color : rgb(37, 37, 37);
font-size: 12px;
padding : 12px 20px;
}
.dropdown-content div:hover{
background-color: rgb(226, 226, 226);
}
.dropdown:hover .dropdown-content {
display: block; /* 마우스 오버 시 드롭다운 표시 */
}
<!-- 드랍다운-->
<!-- 이미지 크기조절 -->
.image-wrapper {
position: relative;
z-index: 1;
}
.image-container {
position: relative;
display: inline-block;
}
.resizable-image {
display: block;
width: 200px;
height: 200px;
user-select: none;
}
.resize-handle {
width: 15px;
height: 15px;
background: red;
position: absolute;
cursor: se-resize;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
}
<!-- table css -->
#tableBtn {
position: relative; /* 기준점 역할 */
z-index: 2; /* 버튼이 위쪽에 있도록 설정 */
}
.table-picker {
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 5px;
border: 1px solid #ccc;
position: absolute;
background: white;
top: 100%; /* 버튼 바로 아래 배치 */
left: 0;
transform: translateY(5px); /* 살짝 아래로 이동 */
z-index: 1000;
display: none; /* 기본적으로 숨김 */
width: auto; /* 내용 크기에 따라 유동적 */
min-width: 120px;
max-width: 300px;
height: auto;
max-height: 200px;
overflow: hidden;
}
.table-picker div {
flex: 1 1 calc(20% - 4px); /* 한 줄에 5개씩 배치 (20% 크기) */
height: 30px; /* 크기 조정 */
background: #eee;
border: 1px solid #ddd;
text-align: center;
line-height: 30px;
font-size: 14px;
}
.table-picker div.selected {
background: #007bff;
}
</style>
</head>
<body>
<div style="width:40%; height: auto">
<div class="editor-menu" style="display : flex; margin-bottom: 10px;">
<div class="dropdown">
<button class="dropBtn">Format ▼</button>
<div class="dropdown-content">
<div name="formatClass" id="p">default</div>
<div name="formatClass" id="h1">H1</div>
<div name="formatClass" id="h2">H2</div>
<div name="formatClass" id="h3">H3</div>
<div name="formatClass" id="h4">H4</div>
<div name="formatClass" id="h5">H5</div>
<div name="formatClass" id="h6">H6</div>
</div>
</div>
<div class="dropdown">
<button class="dropBtn">Size ▼</button>
<div class="dropdown-content">
<div name="fontSizeClass" id="13">default</div>
<div name="fontSizeClass" style="font-size:8px" id="8">8</div>
<div name="fontSizeClass" style="font-size:9px" id="9">9</div>
<div name="fontSizeClass" style="font-size:10px" id="10">10</div>
<div name="fontSizeClass" style="font-size:11px" id="11">11</div>
<div name="fontSizeClass" style="font-size:12px" id="12">12</div>
<div name="fontSizeClass" style="font-size:14px" id="14">14</div>
<div name="fontSizeClass" style="font-size:16px" id="16">16</div>
<div name="fontSizeClass" style="font-size:18px" id="18">18</div>
<div name="fontSizeClass" style="font-size:20px" id="20">20</div>
<div name="fontSizeClass" style="font-size:22px" id="22">22</div>
<div name="fontSizeClass" style="font-size:24px" id="24">24</div>
<div name="fontSizeClass" style="font-size:26px" id="26">26</div>
<div name="fontSizeClass" style="font-size:28px" id="28">28</div>
<div name="fontSizeClass" style="font-size:36px" id="36">36</div>
<div name="fontSizeClass" style="font-size:48px" id="48">48</div>
<div name="fontSizeClass" style="font-size:72px" id="72">72</div>
</div>
</div>
<div class="editorParents">
<div class="editorChild">
<button id="bold" name="b" class="editorBtn">B</button>
<button id="italic" name="i" class="editorBtn">I</button>
<button id="underline" name="u" class="editorBtn">U</button>
<button id="strikeThrough" name="s" class="editorBtn">S</button>
<button id="insertOrderedList" class="editorBtn">OL</button>
<button id="insertUnorderedList" class="editorBtn">UL</button>
<button id="imageBtn" class="editorBtn">IMG</button>
</div>
</div>
<div class="editorParents">
<div class="editorChild">
<button id="justifyLeft" class="arrayBtn">L</button>
<button id="justifyCenter" class="arrayBtn">C</button>
<button id="justifyFull" class="arrayBtn">M</button>
<button id="justifyRight" class="arrayBtn">R</button>
</div>
</div>
<div class="editorParents">
<div class="editorChild">
<button id="tableBtn">T</button>
</div>
</div>
<div class="table-picker" id="table-picker"></div>
</div>
<div id="editor" contenteditable="true" style="width:100%; height: 500px">
<input id="imgTag" type="file" multiple accept="image/*"/>
</div>
</div>
</body>
</html>
<script>
const editor = document.getElementById('editor'); // editor 태그
const imageBtn = document.getElementById('imageBtn'); // 이미지 버튼
const imgTag = document.getElementById('imgTag'); // 이미지 태그
// click 이벤트
document.addEventListener("click", function (event) {
if (isResizing) {
return;
}
document.querySelectorAll('.resize-handle').forEach(handle => {
if (!handle.closest('.image-wrapper') || !handle.closest('.image-wrapper').contains(event.target)) {
handle.style.display = 'none'; // 다른 이미지를 클릭하면 숨김
}
});
// // 이미지 클릭 시 해당 이미지의 핸들 표시
// if (event.target.tagName === "IMG" && event.target.closest(".image-wrapper")) {
// let wrapper = event.target.closest(".image-wrapper");
// let handle = wrapper.querySelector(".resize-handle");
// handle.style.display = "block";
// }
// Format 버튼 클릭
if (event.target.closest("div[name=formatClass]")) {
let formatBtn = event.target.closest("div[name=formatClass]");
if (formatBtn) {
// 선택된 위치가 없을 경우 -> editor 마지막 위치에서
if (!savedSelection) {
moveCursorToLastPosition();
}
// 기존 선택한 Format 버튼 초기화
document.querySelectorAll("div[name=formatClass]").forEach(div => {
div.classList.remove("active");
});
formatBtn.classList.add("active");
let newTag = formatBtn.id.toLowerCase(); // 선택한 태그 값 (예: h1, h2)
let selectedNode = savedSelection.commonAncestorContainer;
// 블록 요소 찾기 (TEXT_NODE일 경우 부모로 이동)
while (selectedNode.nodeType === Node.TEXT_NODE) {
selectedNode = selectedNode.parentNode;
}
// 같은 태그면 변경하지 않음
if (selectedNode.tagName.toLowerCase() === newTag) {
return;
}
// 새 태그 생성 후 내용 복사
let newElement = document.createElement(newTag);
newElement.innerHTML = selectedNode.innerHTML;
// 기존 태그를 새로운 태그로 변경
if (newElement.tagName === "P") {
let parentParagraph = selectedNode.closest("p"); // 가장 가까운 <p> 태그 찾기
if (parentParagraph) {
const cleanElement = document.createElement("p");
// 텍스트만 추출하여 새로운 <p> 태그에 추가
cleanElement.textContent = parentParagraph.textContent;
// 기존 <p>를 새로운 <p>로 변경
parentParagraph.parentNode.replaceChild(cleanElement, parentParagraph);
}
}else{
selectedNode.parentNode.replaceChild(newElement, selectedNode);
}
let selection = window.getSelection();
let range = selection.getRangeAt(0);
let startOffset = range.startOffset; // 기존 커서 위치 저장
let selectedText = range.extractContents(); // 선택한 내용을 저장
let parentElement = selectedNode.parentNode;
// 기존 태그를 새로운 태그로 변경
newElement.appendChild(selectedText);
// Zero-width 추가 (빈 내용 방지)
if (!newElement.textContent.trim()) {
newElement.appendChild(document.createTextNode("\u200B"));
}
// 기존 태그를 새로운 태그로 변경
if(selectedNode.parentNode != null){
selectedNode.parentNode.replaceChild(newElement, selectedNode);
}
// 새로운 태그가 정상적으로 문서에 삽입되었는지 확인
if (!document.body.contains(newElement)) {
return;
}
// 커서를 원래 위치로 복원
let newSelection = window.getSelection();
let newRange = document.createRange();
// 새로운 태그 안의 첫 번째 텍스트 노드 찾기
let textNode = newElement.firstChild || newElement;
newRange.setStart(textNode, Math.min(startOffset, textNode.length));
newRange.collapse(true);
// DOM 반영 후 selection 적용
setTimeout(() => {
newSelection.removeAllRanges();
newSelection.addRange(newRange);
}, 0);
// 선택 영역 저장
savedSelection = newRange;
}
}
// editor style 버튼 클릭
if (event.target.closest(".editorBtn")) {
let button = event.target.closest(".editorBtn");
// 이미지 버튼이 아닐때만 active
if(!(button.id == 'imageBtn')){
document.getElementById(button.id).classList.toggle("active");
}
// 순번 버튼 클릭 시
if(button.id == 'insertOrderedList'){
// UL 버튼에 active 가 되어있다면 해제
if(document.getElementById('insertUnorderedList').classList.length > 1){
document.getElementById('insertUnorderedList').classList.toggle("active");
}
}
// UL 버튼 클릭 시
if(button.id == 'insertUnorderedList'){
// 순번 버튼에 active 가 되어있다면 해제
if(document.getElementById('insertOrderedList').classList.length > 1){
document.getElementById('insertOrderedList').classList.toggle("active");
}
}
editor.focus({ preventScroll: true });
document.execCommand(button.id);
}
// editor 정렬 버튼 클릭
if (event.target.closest(".arrayBtn")) {
let button = event.target.closest(".arrayBtn");
// 선택했던 정렬 버튼 제거
document.querySelectorAll(".arrayBtn").forEach(div => {
div.classList.remove("active");
});
document.getElementById(button.id).classList.toggle("active");
editor.focus({ preventScroll: true });
document.execCommand(button.id);
}
});
// 버튼 클릭 시 에디터가 포커스를 잃기 때문에 다시 에디터에 포커스를 해줌
function focusEditor() {
editor.focus({preventScroll: true});
}
// ************************************************* 이미지 funtion *************************************************
// 이미지 버튼 클릭
imageBtn.addEventListener('click', function () {
imgTag.click();
});
// 이미지파일을 읽어옴
imgTag.addEventListener('change', function (e) {
const files = e.target.files;
if (files) {
// 이미지가 여러개인 경우
for(let i = 0; i < files.length; i++){
insertImageData(files[i]);
}
}
});
function insertImageData(file) {
const reader = new FileReader();
reader.addEventListener('load', function (e) {
focusEditor();
document.execCommand('insertImage', false, `${reader.result}`);
moveCursorToEnd(editor); // 커서를 이미지 뒤로 이동
});
reader.readAsDataURL(file);
}
// 이미지 클릭 시 핸들 추가 이벤트
editor.addEventListener('click', function (e) {
if (e.target.tagName === 'IMG') {
removeExistingResizers(); // 기존 핸들 제거
selectImage(e.target); // 새로운 이미지에 핸들 추가
}
});
// 기존 핸들 제거 함수 (모든 기존 핸들 제거)
function removeExistingResizers() {
document.querySelectorAll('.image-wrapper').forEach(wrapper => {
wrapper.querySelector('.resize-handle')?.remove();
});
}
// 이미지 선택 시 핸들 추가
function selectImage(img) {
if (!img.parentElement.classList.contains('image-wrapper')) {
wrapImageWithResizer(img); // 핸들 및 래퍼 추가
} else {
let wrapper = img.closest('.image-wrapper');
let handle = wrapper.querySelector('.resize-handle');
if (!handle) {
addResizeHandle(wrapper, img); // 핸들 추가
}
}
}
// 이미지에 핸들을 추가하는 함수
function wrapImageWithResizer(img) {
const wrapper = document.createElement('div');
wrapper.classList.add('image-wrapper');
wrapper.style.position = 'relative';
wrapper.style.display = 'inline-block';
wrapper.style.width = `${img.width}px`;
wrapper.style.height = `${img.height}px`;
img.parentNode.insertBefore(wrapper, img);
wrapper.appendChild(img);
addResizeHandle(wrapper, img);
}
// 핸들 추가 함수
function addResizeHandle(wrapper, img) {
const handle = document.createElement('div');
handle.classList.add('resize-handle');
handle.style.width = '10px';
handle.style.height = '10px';
handle.style.background = 'red';
handle.style.position = 'absolute';
handle.style.right = '-5px';
handle.style.bottom = '-5px';
handle.style.cursor = 'nwse-resize';
wrapper.appendChild(handle);
addResizeFunctionality(wrapper, handle, img);
}
// 크기 조절 관련 변수
let isResizing = false;
// 크기 조절 기능 추가
function addResizeFunctionality(wrapper, handle, img) {
let startX, startY, startWidth, startHeight;
handle.addEventListener('mousedown', function (e) {
e.preventDefault();
isResizing = true;
startX = e.clientX;
startY = e.clientY;
startWidth = wrapper.offsetWidth;
startHeight = wrapper.offsetHeight;
document.addEventListener('mousemove', resizeImage);
document.addEventListener('mouseup', stopResize);
});
function resizeImage(e) {
if (!isResizing) return;
let newWidth = Math.max(startWidth + (e.clientX - startX), 40);
let newHeight = Math.max(startHeight + (e.clientY - startY), 40);
//newWidth = Math.min(newWidth, 800); // 최대 크기 제한
//newHeight = Math.min(newHeight, 800); // 최대 높이 제한
newWidth = Math.min(newWidth, document.querySelector('#editor').offsetWidth - 50); // 최대 크기 제한
newHeight = Math.min(newHeight, 500);
wrapper.style.width = `${newWidth}px`;
wrapper.style.height = `${newHeight}px`;
img.style.width = `${newWidth}px`;
img.style.height = `${newHeight}px`;
}
function stopResize() {
isResizing = false;
document.removeEventListener('mousemove', resizeImage);
document.removeEventListener('mouseup', stopResize);
// 크기 조절이 끝나면 z-index 초기화
wrapper.style.zIndex = 'auto';
}
}
// ************************************************* 이미지 funtion *************************************************
// 커서를 에디터 글 마지막으로 이동 *************************************************
function moveCursorToEnd(editor) {
editor.focus({ preventScroll: true });
const selection = window.getSelection();
const range = document.createRange();
let lastChild = editor.lastChild;
// 마지막 요소가 <input>이면 이전 요소를 찾음
while (lastChild && lastChild.nodeType === Node.ELEMENT_NODE && lastChild.tagName === "INPUT") {
lastChild = lastChild.previousSibling;
}
// 추가: 마지막 요소가 <p>나 <li>라면 내부로 이동
if (lastChild && (lastChild.parentElement.tagName === 'P' || lastChild.tagName === "LI")) {
if (lastChild.childNodes.length > 0) {
lastChild = lastChild.lastChild; // 마지막 자식으로 이동
}
}
if (lastChild) {
if (lastChild.nodeType === Node.TEXT_NODE) {
range.setStart(lastChild, lastChild.length);
} else {
range.setStartAfter(lastChild);
}
} else {
range.setStart(editor, 0);
}
// 추가: 마지막 요소가 <br> 태그라면 삭제 후 이동
if (lastChild && lastChild.nodeType === Node.ELEMENT_NODE && lastChild.tagName === "BR") {
lastChild.remove();
}
// 마지막에 빈 텍스트 노드 추가하여 새로운 요소 적용 가능하게 함
range.collapse(true); // 시작점으로 접는다
selection.removeAllRanges();// selection 객체 안에 모든 Range 를 제거해서 아무것도 선택되지 않은 상태로 만듬
selection.addRange(range); // 현재 selection 에 Range 를 추가
}
// 커서를 에디터 글 마지막으로 이동 *************************************************
// 커서가 없는 경우 → editor 마지막 위치로 이동하는 함수 *************************************************
function moveCursorToLastPosition() {
//editor = document.getElementById("editor");
let lastElement = editor.lastChild;
let selection = window.getSelection();
let newRange = document.createRange();
if (!lastElement) {
// editor가 비어 있으면 <p> 추가
let newParagraph = document.createElement("p");
newParagraph.innerHTML = "\u200B"; // 빈 줄 추가
editor.appendChild(newParagraph);
newRange.setStart(newParagraph, 0);
} else {
// editor 내부 마지막 요소 찾기
while (lastElement.lastChild) {
lastElement = lastElement.lastChild;
}
// 마지막 요소가 TEXT_NODE이면 해당 위치로 이동
if (lastElement.nodeType === Node.TEXT_NODE) {
newRange.setStart(lastElement, lastElement.length);
} else {
newRange.setStart(lastElement, 0);
}
}
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
// 선택한 위치 저장
savedSelection = newRange;
}
// 커서가 없는 경우 → editor 마지막 위치로 이동하는 함수 *************************************************
// ************************************************* editor 커서위치 저장 *************************************************
let savedSelection = null; // 커서 위치 저장용 변수
// editor에서 포커스를 잃기 전에 커서 위치 저장
document.getElementById("editor").addEventListener("mouseup", saveSelection);
document.getElementById("editor").addEventListener("keyup", saveSelection);
function saveSelection() {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
savedSelection = selection.getRangeAt(0);
}
}
// ************************************************* editor 커서위치 저장 *************************************************
// ************************************************* editor 기본 설정 *************************************************
document.addEventListener("DOMContentLoaded", function () {
// 에디터에 기본 <p> 태그 추가
function ensureParagraphExists() {
// 에디터 안에 <input> 외의 요소가 없으면 <p> 태그 추가
// 현재 이미지 태그만 들어가 있음
const onlyHasInput = editor.children.length === 1 && editor.firstElementChild.tagName === "INPUT";
if (!editor.innerText.trim() && onlyHasInput) {
const p = document.createElement("p");
p.innerHTML = "\u200B"; // 빈 줄을 유지하여 커서 이동 가능하게 설정
editor.appendChild(p);
}
}
// 포커스할 때 기본 <p> 유지
editor.addEventListener("focus", ensureParagraphExists);
// 키 입력할 때 비어 있는 경우 자동으로 <p> 생성
editor.addEventListener("input", ensureParagraphExists);
// backspace , delete 키 처리
editor.addEventListener("keydown", function (e) {
if(e.key === "Backspace" || e.key === "Delete"){
const selection = window.getSelection();
const range = selection.getRangeAt(0);
let currentNode = range.startContainer;
// 텍스트 노드면 부모 요소로 변경
if (currentNode.nodeType === Node.TEXT_NODE) {
currentNode = currentNode.parentElement;
}
// editor의 직속 자식이 될 때까지 부모를 탐색
let closestNode = currentNode;
while (closestNode.parentElement && closestNode.parentElement !== editor) {
closestNode = closestNode.parentElement;
}
// 현재 editor 내부의 <p> 개수 확인
let paragraphs = editor.querySelectorAll("p");
// 현재 요소가 <p>이고 내용이 비었을 때만 삭제
if (closestNode.tagName === "P" && closestNode.textContent.trim() === "") {
// p 태그가 2개 이상인 경우 태그 삭제
if (paragraphs.length > 1) {
e.preventDefault(); // 기본 동작 방지
editor.removeChild(closestNode);
}else if(paragraphs.length == 1){ // p 태그가 하나만 남은 경우 삭제 방지
e.preventDefault(); // 기본 동작 방지
}
}
}
// Enter 키 처리
if (e.key === "Enter") {
e.preventDefault(); // 기본 Enter 동작 방지
const selection = window.getSelection();
if (!selection.rangeCount) return;
//const range = selection.getRangeAt(0);
const range = window.getSelection().getRangeAt(0);
let currentNode = range.startContainer;
// 텍스트 노드라면 부모 요소 찾기
while (currentNode.nodeType === Node.TEXT_NODE) {
currentNode = currentNode.parentElement;
}
// 현재 활성화된 스타일 저장
const activeStyles = getActiveStyles();
// 리스트 내부인지 확인
let listItem = currentNode.closest("li");
if (listItem) {
const newLi = document.createElement("li");
newLi.innerHTML = "\u200B"; // Zero-width space (커서 유지)
// 새로운 <li> 추가
listItem.parentNode.insertBefore(newLi, listItem.nextSibling);
range.setStart(newLi, 0);
range.setEnd(newLi, 0);
selection.removeAllRanges();
selection.addRange(range);
// 현재 스타일 유지
setTimeout(() => applyActiveStyles(activeStyles), 0);
// 커서 이동
moveCursorToEnd(newLi);
return;
}
const currentParagraph = currentNode;
// 현재 <p> 내부에서 이미지 존재 여부 확인
const hasImage = currentParagraph.querySelector("img") !== null;
if (hasImage) {
// 이미지가 포함된 경우
const childNodes = Array.from(currentParagraph.childNodes);
let beforeNodes = [];
let afterNodes = [];
let foundCursor = false;
let accumulatedLength = 0;
let cursorPos = range.startOffset;
let startContainer = range.startContainer;
// TEXT_NODE가 아니면 커서 위치 보정
if (startContainer.nodeType !== Node.TEXT_NODE) {
let preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(currentParagraph);
preCaretRange.setEnd(range.startContainer, range.startOffset);
cursorPos = preCaretRange.toString().length;
}
// 커서 기준으로 나누기
for (let node of childNodes) {
if (foundCursor) {
afterNodes.push(node);
continue;
}
if (node.nodeType === Node.TEXT_NODE) {
let nodeText = node.textContent;
if (accumulatedLength + nodeText.length >= cursorPos) {
let splitIndex = cursorPos - accumulatedLength;
let beforeText = nodeText.substring(0, splitIndex);
let afterText = nodeText.substring(splitIndex);
if (beforeText) beforeNodes.push(document.createTextNode(beforeText));
if (afterText) afterNodes.push(document.createTextNode(afterText));
foundCursor = true;
} else {
beforeNodes.push(node);
}
accumulatedLength += nodeText.length;
} else {
if (accumulatedLength >= cursorPos) {
afterNodes.push(node);
foundCursor = true;
} else {
beforeNodes.push(node);
}
}
}
// 새로운 <p> 태그 생성
const newParagraph1 = document.createElement("p");
const newParagraph2 = document.createElement("p");
beforeNodes.forEach(n => newParagraph1.appendChild(n));
afterNodes.forEach(n => newParagraph2.appendChild(n));
// 기존 <p> 앞에 추가하고 기존 <p> 삭제
currentParagraph.parentNode.insertBefore(newParagraph1, currentParagraph);
currentParagraph.parentNode.insertBefore(newParagraph2, currentParagraph);
currentParagraph.remove();
// 커서를 새 문단 끝으로 이동
moveCursorToEnd(newParagraph1);
return;
} else {
// 이미지가 없는 경우
const paragraph = document.createElement("p");
paragraph.innerHTML = "\u200B"; // Zero-width space
const cursorPosition = range.startOffset;
const text = range.startContainer.textContent;
if (cursorPosition == text.length) {
// 커서가 문장 끝일 때 새 줄 추가
let styledContainer = paragraph;
if (activeStyles.length > 0) {
activeStyles.forEach(styleTag => {
const newStyledTag = document.createElement(styleTag);
styledContainer.appendChild(newStyledTag);
styledContainer = newStyledTag;
});
styledContainer.textContent = "\u200B"; // Zero-width space
}
currentNode.parentNode.insertBefore(paragraph, currentNode.nextSibling);
} else {
// 커서가 문장 내부일때 텍스트 나누기
const beforeText = text.substring(0, cursorPosition);
const afterText = text.substring(cursorPosition);
range.startContainer.textContent = beforeText;
let styledContainer = paragraph;
if (activeStyles.length > 0) {
activeStyles.forEach(styleTag => {
const newStyledTag = document.createElement(styleTag);
styledContainer.appendChild(newStyledTag);
styledContainer = newStyledTag;
});
}
styledContainer.textContent = afterText;
currentNode.parentNode.insertBefore(paragraph, currentNode.nextSibling);
}
// 커서 이동
moveCursorToEnd(paragraph);
}
}
});
editor.addEventListener("dragstart", function (event) {
if (event.target.tagName.toLowerCase() === "img") {
removeExistingResizers(); // 기존 핸들 제거
}
});
// 커서 이동 시 버튼 상태 갱신
document.addEventListener("selectionchange", function () {
updateButtonStates();
updateFormatStates();
});
/**
* 현재 커서 위치에서 적용된 스타일에 따라 버튼 상태 업데이트
*/
function updateButtonStates() {
const buttons = document.querySelectorAll(".editorBtn");
buttons.forEach((button) => {
if (document.queryCommandState(button.id)) {
button.classList.add("active");
} else {
button.classList.remove("active");
}
});
}
function updateFormatStates() {
const selection = window.getSelection();
if (!selection.rangeCount) return;
let selectedNode = selection.getRangeAt(0).commonAncestorContainer;
// 텍스트 노드일 경우 부모 요소 찾기
while (selectedNode.nodeType === Node.TEXT_NODE) {
selectedNode = selectedNode.parentNode;
}
const formats = document.querySelectorAll("div[name=formatClass]");
formats.forEach((format) => {
if (selectedNode.tagName.toLowerCase() === format.id.toLowerCase()) {
format.classList.add("active");
} else {
format.classList.remove("active");
}
});
}
// 초기 에디터 상태 보정
ensureParagraphExists();
});
// ************************************************* editor 기본 설정 *************************************************
// ************************************************* 현재 활성화된 스타일 *************************************************
function getActiveStyles() {
const appliedStyles = [];
const selection = window.getSelection();
if (!selection.rangeCount) return appliedStyles;
const range = selection.getRangeAt(0);
let currentNode = range.startContainer;
// 텍스트 노드이면 부모 요소 찾기
while (currentNode.nodeType === Node.TEXT_NODE) {
currentNode = currentNode.parentElement;
}
// 줄바꿈 시 format 이 풀리게 설정해두었음
const formatButtons = document.querySelectorAll("div[name=formatClass]");
formatButtons.forEach((button) => {
if (button.classList == 'active') {
button.classList.remove('active');
// h1~6 의 경우 html 에서 bold 효과를 가져가고 있기 때문에 해제되면 bold 도 해제 처리 해야함
if(button.id != 'p'){
document.getElementById("bold").classList.toggle("active")
document.execCommand("bold");
}
}
});
// 현재 커서가 위치한 노드에서 버튼 ID와 매칭되는 스타일 확인
const buttons = document.querySelectorAll(".editorBtn");
buttons.forEach((button) => {
if (document.queryCommandState(button.id)) {
appliedStyles.push(button.name);
}
});
return appliedStyles;
}
// ************************************************* 현재 활성화된 스타일 *************************************************
// ************************************************* 현재 활성화된 버튼 스타일 적용 *************************************************
function applyActiveStyles(styles) {
styles.forEach((style) => {
document.execCommand(style, false, null);
});
}
// ************************************************* 현재 활성화된 버튼 스타일 적용 *************************************************
// ************************************************* 테이블 삽입 *************************************************
function insertTable(rows, cols) {
//focusEditor();
//moveCursorToEnd(editor);
let tableHTML = `<table border="1" style="width:100%;border-collapse: collapse;">`;
for (let r = 0; r < rows; r++) {
tableHTML += "<tr>";
for (let c = 0; c < cols; c++) {
tableHTML += `<td contenteditable="true" style="padding: 5px;">-</td>`;
}
tableHTML += "</tr>";
}
tableHTML += `</table><br>`;
// savedSelection이 존재하면 해당 위치에 삽입
if (savedSelection) {
savedSelection.deleteContents(); // 선택된 영역을 삭제
let div = document.createElement('div');
div.innerHTML = tableHTML;
let table = div.firstChild; // 첫 번째 자식인 table 요소
savedSelection.insertNode(table); // 선택된 위치에 테이블 삽입
// 테이블 삽입 후 커서가 테이블 뒤로 이동하도록 설정
let range = document.createRange();
range.setStartAfter(table); // 테이블 뒤로 커서 이동
range.setEndAfter(table);
let selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}else{
// savedSelection이 없다면 (처음 클릭 시)
moveCursorToLastPosition(); // 커서를 마지막 위치로 이동시키기
let div = document.createElement('div');
div.innerHTML = tableHTML;
let table = div.firstChild; // 첫 번째 자식인 table 요소
// 선택된 범위가 없을 경우에도 커서 위치를 마지막으로 이동시킨 후 테이블 삽입
let range = document.createRange();
range.selectNodeContents(editor); // editor의 모든 내용을 범위로 설정
let selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range); // 새로 선택 범위 설정
range.deleteContents(); // 범위 내 내용 삭제
range.insertNode(table); // 선택된 위치에 테이블 삽입
}
}
const tableBtn = document.getElementById("tableBtn");
const tablePicker = document.getElementById("table-picker");
// 버튼 클릭 시 테이블 선택 UI 표시
tableBtn.addEventListener("click", (e) => {
tablePicker.style.display = "grid";
tablePicker.style.left = `${e.clientX}px`;
tablePicker.style.top = `${e.clientY + 30}px`;
createTablePicker(); // 테이블 선택 UI 생성
});
// 테이블 선택 UI 생성
function createTablePicker(rows = 5, cols = 5) {
tablePicker.innerHTML = ""; // 기존 UI 초기화
tablePicker.style.display = "grid";
tablePicker.style.gridTemplateColumns = `repeat(${cols}, 20px)`;
tablePicker.style.gridTemplateRows = `repeat(${rows}, 20px)`;
for (let r = 1; r <= rows; r++) {
for (let c = 1; c <= cols; c++) {
const cell = document.createElement("div");
cell.dataset.row = r;
cell.dataset.col = c;
//cell.textContent = `${r}x${c}`; // 크기 표시
cell.addEventListener("mouseover", (e) => highlightCells(e.target.dataset.row, e.target.dataset.col));
cell.addEventListener("click", (e) => {
insertTable(e.target.dataset.row, e.target.dataset.col);
tablePicker.style.display = "none";
});
tablePicker.appendChild(cell);
}
}
}
// 마우스로 크기 선택 시 하이라이트
function highlightCells(rows, cols) {
document.querySelectorAll(".table-picker div").forEach((cell) => {
const r = cell.dataset.row, c = cell.dataset.col;
cell.classList.toggle("selected", r <= rows && c <= cols);
});
}
// 편집 영역 바깥 클릭 시 닫기
document.addEventListener("click", (e) => {
if (!tablePicker.contains(e.target) && e.target !== tableBtn) {
tablePicker.style.display = "none";
}
});
// ************************************************* 테이블 삽입 *************************************************
// ************************************************* font-size 변경 *************************************************
document.querySelectorAll('[name="fontSizeClass"]').forEach(item => {
item.addEventListener("mousedown", function(event) { // drag 했을때
event.preventDefault(); // font 클릭 시 drag 한 부분 selection 해제 방지
let pxSize = parseInt(this.id);
let fontSizeMap = {
8: 1, 9: 1, 10: 2, 11: 2, 12: 3, 14: 4,
16: 4, 18: 5, 20: 5, 22: 5, 24: 6,
26: 6, 28: 6, 36: 7, 48: 7, 72: 7
};
let execValue = fontSizeMap[pxSize] || 3;
let selection = window.getSelection();
let range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
editor.focus({ preventScroll: true });
if (range && !range.collapsed) {
// 드래그한 텍스트가 있을 때
document.execCommand("fontSize", false, execValue);
// <font size="N"> 으로 생성되는데 → <p style="font-size:Npx;"> 으로 변경
setTimeout(() => {
let fontElements = editor.querySelectorAll("font[size]");
fontElements.forEach(font => {
font.style.fontSize = pxSize + "px";
font.removeAttribute("size");
font.outerHTML = `<p style="font-size:${pxSize}px;">${font.innerHTML}</p>`;
});
}, 0);
} else {
// 선택된 영역이 없을 때
document.execCommand("styleWithCSS", false, true);
document.execCommand("fontSize", false, execValue);
// 새로운 <p> 태그 생성
let pTag = document.createElement("p");
pTag.style.fontSize = pxSize + "px";
pTag.innerHTML = "​"; // 커서 유지
// 현재 선택된 노드 가져오기
let selectedNode = selection.focusNode;
let currentTag = selectedNode.parentElement;
// <p> 태그 안에 <p> 태그가 들어가지 않도록 처리
if (currentTag.tagName === "P") {
// 현재 <p> 태그의 다음에 삽입
if (currentTag.nextSibling) {
currentTag.parentNode.insertBefore(pTag, currentTag.nextSibling);
} else {
currentTag.parentNode.appendChild(pTag);
}
} else {
// p 태그가 아닐 경우 추가
editor.appendChild(pTag);
}
// 커서를 pTag 안으로 이동
let range = document.createRange();
range.setStart(pTag, 0);
range.setEnd(pTag, 0);
selection.removeAllRanges();
selection.addRange(range);
}
});
});
// ************************************************* font-size 변경 *************************************************
</script>
ChatGPT 가 정말 똑똑하다고 느끼면서도 너무 멍청하다는 걸 새삼스레 다시 느꼈다
매번 같은 코드만 알려주거나 자신이 알려준 코드를 자신이 모른다
간단하게 구현하고 코드의 흐름을 알고 싶어서 사용했다가
나중엔 답답해서 다 뒤엎고 일일이 처음부터 다시 짜고 있는 나를 발견하더라...
그나저나 이젠 인간처럼 느껴지는데...?
'개발 > JavaScript' 카테고리의 다른 글
[JavaScript] 브라우저 개발자 도구(F12) 막기 (0) | 2025.03.12 |
---|---|
[JavaScript] Kakao 지도 API 사용하기(주소로 여러개 마커 표시하기) (0) | 2025.03.11 |
[JavaScript] Kakao 지도 API 사용하기(403 에러 포함) (2) | 2025.03.10 |
[JavaScript] 체크 된 행의 데이터 가져오기 (0) | 2025.02.23 |
[JavaScript] CheckBox 전체 선택, 전체 해제 하기 (0) | 2025.02.23 |
댓글