본문 바로가기
개발/JavaScript

[JavaScript] Editor 만들기(바닐라, Vanilla)

by 코딩하는 흰둥이 2025. 3. 29.


우리가 흔히 사용하는 글 작성 Editor 를 한번 만들어보고 싶어졌다

그리고...난 너무 후회했다, 시작을 하지 말걸....

 

요즘 jQuery 를 사용하지 않는 곳이 없다지만

공부 겸, 어디서든 사용할 수 있도록 jQuery 를 배제하고 순수 Javascript 로만 코드를 만들고 싶었다

 

개발자로 일해오면서 거진 대부분은 jQuery 만 사용해 왔고

순수 Javascript 로는 getElementById 또는 querySelector 같은 단순한 것 만들 사용 해봐서

개발 방향 및 방식은 내 생각대로 하고, 코드 구현은 ChatGPT 로 하였다

(개인적으로 ChatGPT 로 개발하는 걸 좋아하지 않는다)

 

jQuery 로 했으면 이렇게까지 고생하지 않고 ChatGPT 없이 만들었을 거 같은데...
만들다 지치다 못해 손을 놓고 싶어졌다

 

현재 이 코드는 100% 완벽하게 정상동작 하는 코드는 아니며,

웬만하면 무료 editor 를 사용하길 바란다


 

 

참고
  1. editor 를 만들기 위해서는 편집을 자유자대로 할 수 있게 contenteditable="true" 을 사용해야 한다
  2. execCommand 는 선택된 영역을 변경해 주는 편집기능 함수인데 이제는 지원되지 않는 함수이다
  3. 현재 코드에서는 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 = "&ZeroWidthSpace;"; // 커서 유지

        // 현재 선택된 노드 가져오기
        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 가 정말 똑똑하다고 느끼면서도 너무 멍청하다는 걸 새삼스레 다시 느꼈다

매번 같은 코드만 알려주거나 자신이 알려준 코드를 자신이 모른다

간단하게 구현하고 코드의 흐름을 알고 싶어서 사용했다가

나중엔 답답해서 다 뒤엎고 일일이 처음부터 다시 짜고 있는 나를 발견하더라...

그나저나 이젠 인간처럼 느껴지는데...? 

 

댓글