명언 탭

올리·2023년 12월 10일
0
post-thumbnail


개요

명언을 작성하면, 랜덤으로 메인 탭에 띄워준다.

cf. 포스팅은 더 늦게 했지만 실제로 명언 요소보다 시간표 요소를 더 나중에 만들었다.

전역 변수

전체 요소를 통틀어 유일하고 여러 함수에서 재사용되는 요소들을 전역으로 빼줬다.

import { getDate } from '../utility/date.js';

const desktop = document.querySelector('#desktop');
const lifeQuoteMap = new Map();

let lifeQuoteEl = null; // Life quote element (Root Element)
let tabListItems = null;
let contentBody = null;
let contextMenu = null;
  • desktop: 요소 좌표 계산하고 붙여줄 때 사용
  • tabListItems: 탭 요소들
  • contentBody: 실제 내용을 띄우는 부분
  • lifeQuoteEl: 전체 요소
  • contextMenu: 컨텍스트 메뉴. 우클릭 시 등장
  • counter: 명언 정보 저장할 때 key값으로 사용
  • lifeQuoteMap: 명언 정보를 저장하는 맵. (키, 명언 정보 객체) 형식으로 사용한다.

탭 이동

<ul id="lifequote-tablist" role="tablist">
    <li id="lifequote-main-tab" role="tab" aria-selected="true">
    <a href="#tabs">Today's Life Quote </a>
    </li>
    <li id="lifequote-list-tab" role="tab"><a href="#tabs">Quote List</a></li>
    <li id="lifequote-edit-tab" role="tab"><a href="#tabs">Edit</a></li>
    <li id="lifequote-help-tab" role="tab"><a href="#tabs">Help</a></li>
</ul>
<div id="lifequote-content-body" class="window" role="tabpanel">
     <div class="window-body">
          <div id="content-container">
               <p id="lifequote-print">Please add a new quote.</p>
          </div>
      </div>
</div>

탭 형태는 window98.css에 있던 형식을 그대로 사용했다. 각 탭을 누르면 탭에 해당하는 내용을 content-body 이하에 띄운다.

  • createlifeQuoteEl
tabListItems = lifeQuoteEl.querySelectorAll('#lifequote-tablist li');

...

tabListItems.forEach((item) => {
    item.addEventListener('click', clickTab);
});

이를 위해 전체 요소(lifequoteEl)를 만들 때 각 탭에 이벤트 리스너를 부여해줬다.

const clickTab = (e) => {
    // Initialize the status of the all opened tabs
    tabListItems.forEach((item) => item.setAttribute('aria-selected', 'false'));

    // Change the clicked tab to selected state
    const clickedTab = e.currentTarget;
    clickedTab.setAttribute('aria-selected', 'true');

    const tabContent = clickedTab.querySelector('a').textContent.trim();
    switch (tabContent) {
        case "Today's Life Quote":
            setLifeQuoteContent();
            break;
        case 'Edit':
            setInputContent();
            break;
        case 'Quote List':
            setFileListContent();
            break;
        case 'Help':
            setHelpContent();
            break;
        default:
            contentBody.innerHTML = `<p>Error</p>`;
    }
};

탭에 해당하는 내용을 띄우기에 앞서 우선 모든 탭의 선택 상태를 초기화해준다. 선택된 탭을 저장해둘까 싶었지만 탭이 4개밖에 없으니 그냥 for문을 사용했다.
초기화 이후 클릭된 탭의 selected 속성을 true로 변경해준다.

그리고 클릭된 탭의 textContext를 따와서 그에 맞는 내용을 세팅할 수 있도록 switch문을 사용했다.
처음에 lifeQuote창을 열었을 때는 Today's Life Quote가 선택되어 있으므로 default는 에러 상태일 때만 들어가게 된다.

그런데 이런 방식을 사용하면 탭을 옮겨갈 때마다 매번 재렌더링이 일어나고, 그에 따라 이벤트 리스너도 중첩되는 문제가 있었다. 일단 이대로 만들기로 했으니 갈아엎지 않고 최대한 중첩을 줄이려고 했지만, 다음 요소를 만들 때는 차라리 첫 실행 때 모든 요소들을 렌더링하되 CSS 클래스 등을 이용해서 보이고 안 보이고를 조절해야겠다고 생각했다.
(이후 적용: 시간표 포스팅)


명언 출력 탭


위는 추가한 명언 정보가 아무것도 없을 때 뜨는 문구이고,

추가한 명언이 하나라도 있으면 위처럼 명언을 띄워준다.

함수는 비교적 간단하다.
먼저 명언을 저장해둔 Map의 크기가 0이면 별다른 작업 없이 바로 리턴한다. 그러면 getInnerHtmlOf~함수에 따라 상기한 디폴트 문구가 뜨게 된다.

const setLifeQuoteContent = () => {
  contentBody.innerHTML = getInnerHtmlOfContentEl();

  if (lifeQuoteMap.size === 0) return;

  const keysArray = Array.from(lifeQuoteMap.keys());
  const randomIndex = Math.floor(Math.random() * keysArray.length);
  const randomKey = keysArray[randomIndex];

  const printTextEl = contentBody.querySelector('#lifequote-print');
  const textToPrint = lifeQuoteMap.get(randomKey).text;
  const authorToPrint = lifeQuoteMap.get(randomKey).author;

  printTextEl.textContent = textToPrint;
  //printTextEl.innerHTML = textToPrint.replace(/\n/g, '<br>');  // 줄바꿈 문자(\n)를 HTML 줄바꿈 태그 <br> 로 변환
  printTextEl.insertAdjacentHTML('beforeend', `<br><br> - ${authorToPrint} -`);
  printTextEl.insertAdjacentHTML('afterend', `<br>`);
};

만약 출력할 내용이 있다면 랜덤 요소를 뽑기 위해 Map을 배열로 옮기는 과정을 거친다.
Map의 키만 가져와서 배열을 만든 뒤, 배열 크기 미만의 난수를 뽑아 인덱스에 적용해서 랜덤 키를 얻는다. 그러면 다시 Map에 이 랜덤 키를 사용해서 데이터에 접근할 수 있다.
이렇게 Map을 사용하면 키를 전부 배열에 넣어주는 작업이 필요해서 명언들을 저장할 자료구조를 배열로 만드는 것도 고민했었다. 하지만 이런 출력 기능보다 키를 이용한 검색 기능을 내부적으로 더 많이 사용할 것 같아 그냥 Map을 선택했다.

이후 얻어온 데이터들을 화면에 출력해준다. 명언은 textContent를 이용해 바로 띄웠고, 인물과 줄바꿈은 textContent와의 관계를 생각해서 insertAdjacentHTML을 통해 출력해줬다. 마지막은 인물 정보가 창과 너무 바짝 붙어있는 걸 막기 위한 줄바꿈이다.

여기서 입력에 줄바꿈이 있다면 출력에서도 그걸 똑같이 반영해주려고 innerHTML을 통해 <br> 태그를 추가해주려고 했지만... 역시 사용자 입력을 그대로 innerHTML에 적용하기는 꺼려졌고, 기획 의도가 명언인만큼 줄바꿈이 중요할 정도의 긴 문장을 고려해야 하나 싶기도 해서 결국 textContent로 변경했다.


명언 리스트 탭

저장되어있는 모든 명언을 차트처럼 보여준다.
특정 줄을 클릭하면 파랗게 강조 표시가 되고, 이때 우클릭을 하면 해당 요소를 수정하거나 삭제할 수 있는 메뉴가 뜬다.
삭제는 바로 이루어지고, 수정을 선택하면 Edit 탭으로 넘어간다.

무난할 줄 알았는데 완전 복병이었던 부분이었다...
리스너 부여/삭제부터 메뉴 좌표 설정/이동이 예상보다 까다로웠다.
그래서 그냥 디자인을 구현하기 쉽게 갈아엎어 버릴까 싶기도 했지만(=컨텍스트메뉴 삭제) 어설프게라도 구현해보고 싶어서 어디까지 가나 보자 하고 진행했다.

Map 출력

// 맵 요소들을 테이블에 출력
const setFileListContent = () => {
  contentBody.innerHTML = getInnerHtmlOfFileListEl();

  lifeQuoteMap.forEach((item, key) => {
    printQuoteMap(key, item);
  });
  
  
  setTableEventListeners(); // 리스트 table에 필요한 모든 리스너 세팅
};

const printQuoteMap = (key, item) => {
  const tBody = contentBody.querySelector('tbody');
  const row = tBody.insertRow();

  const lifeQuoteCell = row.insertCell(0);
  lifeQuoteCell.textContent = cutTextToPrint(item.text, 12);

  const authorCell = row.insertCell(1);
  authorCell.textContent = cutTextToPrint(item.author, 6);

  const dateCell = row.insertCell(2);
  dateCell.textContent = item.date;

  lifeQuoteCell.parentElement.setAttribute('data-key', key); // 이 데이터의 담당 row에 이 데이터의 key를 부여해줌 
};

출력은 단순하게 탭이 눌릴 때마다 forEach문을 통해 Map 요소들을 출력해준다. 이때 명언 리스트에서 어떤 줄을 선택했을 때 그 내용에 맞는 수정, 삭제로 들어가기 위해서 row(lifeQuoteCell.parentElement)의 data-key를 해당 요소의 key로 설정해준다.

cutTextToPrint는 말 그대로 리스트에 미리보기 데이터를 띄울 때 텍스트 크기에 맞춰 창이 너무 커지는 것을 방지하기 위해 만들었다. 길이가 max를 넘기면 그 이하를 ...로 바꾼 텍스트를 리턴해준다.

// Check length of the text and readjust it if it exceeds the limit
const cutTextToPrint = (text, max) => {
  const check = text.length > max ? true : false;
  if (check) return text.slice(0, max) + '...';
  else return text;
};

이벤트 리스너

복병이었던 이벤트 처리!!!!
대충 구조는 이러하다.

  • lifeQuoteEl 자체에 클릭 이벤트 부여
    : 선택됐던 열이 있는 경우 선택 해제,
    contextMenu가 띄워져 있었다면 삭제

  • 어떤 줄(열)을 클릭
    : 클릭한 줄은 강조, 기존에 클릭됐던 줄은 강조 해제
    -> 클릭한 줄에 우클릭 이벤트(contextMenu 띄우기) 리스너 부여

  • contextMenu가 있는 상황에서 lifeQuoteEl을 드래그해 옮기는 경우
    : contextMenu의 좌표를 그에 맞춰 옮겨줘야 함

쓰고 보니까 그렇게 복잡하진 않은데 이벤트 리스너 콜백으로 화살표 함수를 마구 사용했더니 참사가 일어났다.

문제인즉슨 위 리스너들을 활성화시키고 다른 탭에 갔다가 다시 돌아오면(=재렌더링 될 때마다) 기존 리스너가 남아있음 + 새 리스너 추가로 리스너 콜백함수 트리거가 계속 증가하는 것이었다.
그래서 기존 리스너를 삭제해보려 했는데...

  • removeEventListener 사용?
    이 함수는 추가했던 함수와 똑같은 객체를 줘야 정상 작동하는데 탭을 이동할 때마다 요소들이 새로 렌더링돼서 기존 객체를 넘겨주기가 애매했다. 탭 이동(clickTab)시에 전달해줄 수 있긴 하지만 코드가 필요 이상으로 지저분/복잡해지는 것 같아서 패스...
  • 탭 이동할때마다 새로 렌더링되는 요소들
    : 콜백을 화살표로 줘도 상관없음

  • lifeQuoteEl(전체 창 요소)
    : 탭을 왔다갔다 해도 그 탭들의 부모 요소인 lifeQuoteEl은 변하지 않으므로 콜백 화살표로 주면 addEventListener를 만날 때마다 새 함수 객체가 생김
    -> 몇 번 탭을 왔다갔다 해주면 오병이어의 기적 일어남
    -> removeEventListener 쓰거나 전역에 저장되는 이름을 가진 함수를 콜백으로 등록해줘야 함

addEventListener의 콜백에서 동일한 함수 객체를 참조할 수 있게 해 주면 addEventListener가 여러 번 실행돼도 각 클릭 이벤트에서 같은 함수를 참조(=한 번만 실행)하기 때문이다.
따라서 매번 새로 렌더링되는 table에 대한 리스너 콜백은 화살표여도 무방하지만, 전체 요소 lifeQuoteEl에 대한 콜백은 정확한 함수 객체를 전달해줘야 한다.

  • 이름 전달 버전
const setTableEventListeners = () => {
  const clickRow = (e) => {
    // Check if there is already selected row
    if (highlighted) highlighted.classList.remove('highlighted');

    const clickedRow = e.target.parentElement;
    clickedRow.classList.add('highlighted');

    // Add right click event when normal click event occurs
    clickedRow.addEventListener('contextmenu', rightClickRow);
  };
  
  table.addEventListener('click', clickRow);
  • 화살표 함수 버전
const setTableEventListeners = () => {
  // 바로 화살표로 달아줌
 table.addEventListener('click', (e) => {
	console.log('tableclicktest');
     Check if there is already selected row
     if (highlighted) highlighted.classList.remove('highlighted');

     const clickedRow = e.target.parentElement;
     clickedRow.classList.add('highlighted');

     // Add right click event when normal click event occurs
     clickedRow.addEventListener('contextmenu', rightClickRow);
   });

근데 여기서 또 개념이 헷갈렸다.
화살표 함수는 익명 함수인데 화살표 함수로 만든 함수를 리스너 콜백으로 줘도 되나?
-> 화살표 '함수' 자체는 익명이 맞지만 함수 '표현식'으로 할당해 사용했으므로 리스너는 전역에서 해당 함수 표현을 찾아낼 수 있다.

const setTableEventListeners = () => {
  // 요소마다 핸들러를 할당하지 않고, 요소의 공통 조상에
  // 이벤트 핸들러를 하나만 할당해도 여러 요소를 한꺼번에 다룰 수 있다.
  const fileListEl = contentBody.querySelector('#lifequote-filelist');
  const table = contentBody.querySelector('#lifequote-filelist-table');
  let highlighted = table.querySelector('.highlighted');

  const rightClickRow = (e) => {
    const clickedRow = e.target.parentElement;
    // Exit if contextMenu already exists or no column is selected
    if (document.querySelector('#context-menu') || !clickedRow) return;
    setContextMenu(e);
  };
  
  const clickRow = (e) => {
    // Check if there is already selected row
    if (highlighted) highlighted.classList.remove('highlighted');

    const clickedRow = e.target.parentElement;
    clickedRow.classList.add('highlighted');

    // Add right click event when normal click event occurs
    clickedRow.addEventListener('contextmenu', rightClickRow);
  };

  const clickQuoteListEl = (e) => {
    highlighted = table.querySelector('.highlighted'); // have to get it in real time

    if (highlighted && !table.contains(e.target))
      highlighted.classList.remove('highlighted');
  };

  table.addEventListener('click', clickRow);
  fileListEl.addEventListener('click', clickQuoteListEl);
  //마지막은 처음에 desktop, El에 달았는데 얘네는 초기화가 따로 안돼서 계속 리스너 중첩됨
  //얘랑 드래그 이벤트랑 아예 createElement할 때 리스너 붙여주고 변수들은 외부로 빼야하나

  // Add event listeners required when a context menu exists
  lifeQuoteEl.addEventListener('dragend', dragLifeQuoteElWithContextMenu);
  lifeQuoteEl.addEventListener('click', clickLifeQuoteElWithContextMenu);
};

컨텍스트 메뉴

특정 열을 클릭해서 선택한 뒤 우클릭을 하면 컨텍스트 메뉴가 뜬다.
contextMenulifeQuote에서 하나만 띄울 수 있고 여러 함수에서 접근하므로 전역에서 관리한다.

const setContextMenu = (e) => {
  e.preventDefault(); // 브라우저의 우클릭 기본 동작 방지

  // Create context menu
  contextMenu = document.createElement('div');
  contextMenu.id = 'context-menu';
  getContextMenuPos(); // 클릭한 줄 밑에 붙여줌
  
  //contentBody.append(setContextMenuItem()); // 안됨
  desktop.append(setContextMenuItem());
};

생성 후 contentBody가 아니라 desktop에 붙여준다.
왜냐면 getContextMenuPos에서 getBoundingClientRect, 즉 뷰포트를 기준으로 위치를 계산해주는 함수를 사용했기 때문에 전체 화면을 커버하는 desktop 엘리먼트에 붙여줘야 한다.
contentBodylifeQuote 창에서도 일부만 차지하고 있기 때문에 여기다 붙이면 둘이 따로 놀게 된다.

const getContextMenuPos = () => {
  const quoteTable = contentBody.querySelector('.sunken-panel');
  const clickedRow = contentBody.querySelector('.highlighted');

  contextMenu.style.left = quoteTable.getBoundingClientRect().left + 'px';
  contextMenu.style.top = clickedRow.getBoundingClientRect().bottom + 'px';
};

테이블의 left와 선택한 열의 bottom을 이용해서 선택된 열 바로 밑에 컨텍스트 메뉴를 달아줬다.

아래는 뼈대만 있는 contextMenu에 세부 컨트롤을 달아주는 함수이다.

const setContextMenuItem = () => {
  contextMenu.innerHTML = getInnerHtmlOfContextMenuEl();

  contextMenu
    .querySelector('#contextmenu-edit-li')
    .addEventListener('click', () => {
      clickEditInContextMenu();
      contextMenu.remove();
    });
  contextMenu
    .querySelector('#contextmenu-remove-li')
    .addEventListener('click', () => {
      clickRemoveInContextMenu();
      contextMenu.remove();
    });

  return contextMenu;
};

하나는 edit 기능이고 남은 하나는 remove해줘야 해서 따로 리스너 함수를 만들지 않고 화살표 함수에 넣어줬다. 어차피 컨텍스트 메뉴는 새로 만들었다 지웠다 하니까 리스너가 중첩될 염려도 없었다.

const clickRemoveInContextMenu = () => {
  const clickedRow = contentBody.querySelector('.highlighted');
  const selectedKey = parseInt(clickedRow.getAttribute('data-key'));
  lifeQuoteMap.delete(selectedKey);
  clickedRow.remove(); // 표에서 클릭된 행을 삭제
};

삭제를 선택했을 때는 그 열에 부여했던 data-key 속성을 가져오고 그걸로 맵 데이터도 삭제, 열 자체도 삭제해준다.

참고로 계속 highlighted 요소를 함수마다 계속 가져오는 이유는 언제 사용자가 다른 곳을 클릭해서 하이라이트가 해제됐을지 모르기 때문이다. 그래서 실시간 체크 상태를 볼 겸 지역 변수로 계속 가져오고 있다.

const clickEditInContextMenu = () => {
  const clickedRow = contentBody.querySelector('.highlighted');
  const selectedKey = parseInt(clickedRow.getAttribute('data-key'));
  
  // 탭 선택 상황 변경
  tabListItems[1].setAttribute('aria-selected', 'false');
  tabListItems[2].setAttribute('aria-selected', 'true');
  
  // 입력창에 정보 전달
  setInputContent(selectedKey);
};

수정 버튼을 누르면 수정 탭으로 넘어가야 하고, 기존 데이터들을 가져와서 입력창에 뿌려줘야 한다.
tabListItems[1] 이런 식으로 탭을 가져오는게 좀 투박한가 싶기도 했지만... 리팩토링하더라도 굳이 탭을 늘릴까 싶어서 그냥 투박한 배열 선택으로 놔뒀다.


명언 입력 탭


보이는 그대로의 동작을 한다.
입력이 주어지면 저장한다.
저장하면 다른 탭으로 넘어가지는 않고 이 탭에 머물지만 텍스트박스들은 초기화되도록 만들었다.

입력창 만들어줄 때 만약 selectedKey가 넘어오면 받은 키에 해당하는 내용들을 입력창에 띄워준다.
그 외에는 무난한 함수였다.

const setInputContent = (selectedKey) => {
  contentBody.innerHTML = getInnerHtmlOfInputEl();

  const textEl = contentBody.querySelector('#lifequote-textarea');
  const authorEl = contentBody.querySelector('input');

  // Setting the contents of the row selected from fileList
  if (selectedKey) {
    textEl.value = lifeQuoteMap.get(selectedKey).text;
    authorEl.value = lifeQuoteMap.get(selectedKey).author;
  }

  const saveBtn = contentBody.querySelector('#lifequote-save-btn');
  const clearBtn = contentBody.querySelector('#lifequote-clear-btn');
  saveBtn.addEventListener('click', () =>
    createQuote(selectedKey, textEl, authorEl),
  );
  clearBtn.addEventListener('click', () => {
    textEl.value = authorEl.value = '';
  });
};

명언 데이터를 생성하는 함수

const createQuote = (selectedKey, textEl, authorEl) => {
  // 입력이 비었는지 확인
  if (textEl.value.trim().length === 0 || authorEl.value.trim().length === 0) {
    textEl.value = authorEl.value = '';
    alert('Please enter texts.');
    return;
  }

  // 데이터 세팅
  // 수정으로 넘어왔으면 기존 키 사용, 아니면 새 키 부여
  const key = selectedKey ? selectedKey : counter++;
  const today = getDate();
  const newQuote = {
    text: textEl.value,
    author: authorEl.value,
    date: `${today.day}, ${today.month}/${today.date}/${today.year}`,
  };

  // 맵에 넣기
  try {
    lifeQuoteMap.set(key, newQuote);
  } catch (err) {
    alert(`${err.name}: ${err.message}`);
    setInputContent();
  }

  alert('It has been saved.');
  textEl.value = authorEl.value = '';
};

입력이 비었는지 확인하는 코드는 유틸리티로 따로 빼도 될 것 같다.
각 데이터를 식별하기 위해 key가 필요하지만 지금은 따로 디비나 백엔드를 사용하지 않기 때문에 간단하게 전역변수 카운터값을 부여해줬다.


도움말 탭

Life Quote 창을 어떻게 이용하면 되는지에 대한 안내를 출력한다.

다른 탭들에 비해서 별거 없는 부분이다. (CSS 덕분에~~)
말 그대로 안내를 출력하는데, +- 버튼 빼면 동적이랄 게 없다.


  • 자료구조 변경: 배열 -> Map
    왜 처음부터 생각을 못했을까 싶은 부분.
    JS 공부할 때 데이터 저장하고 사용하는 실습이 거의 배열로 이루어져서 눈에 익어버렸고, JS만의 배열 메소드들이 신기해서 공부할 겸 나도 모르게 배열을 사용했었다. 그러다 역시 key를 찾아야 할 때 find() 돌릴 바에 그냥 map이나 set을 쓰면 되지 않을까? 하는 생각이 뒤늦게 들었다.
    map과 set 중에서는 요소들을 출력할 때 정렬이 필요해서 Map을, weak형은 key 값을 객체로 하고 싶지 않아서 일반 Map을 선택했다.

eventListener의 콜백 함수 형식

화살표: 함수를 여러 개 만들지 않아도 됨(네이밍 덜 해도 됨, 필요한 기능을 바로 사용한다는 점에서 가독성 좋음)
단 이벤트가 일어날 때마다 새로 호출되므로 이벤트 리스너가 계속 쌓일 수 있음 -> 제거해주거나 이를 방지해야 함
this 바인딩 불가능

일반 기명함수: 이벤트가 여러 번 발생해도 엔진이 알아서 새 이벤트 리스너로 덧붙여줌, this 바인딩 가능
단 화살표 함수처럼 간편하게 쓰지 못하고 하나씩 명명해서 써줘야 함

탭 초기화될 때 vs. 아닐 때의 처리

  • edit
    • 그냥 넘어갔을 때
    • 수정으로 넘어갔을 때: 기존 데이터 띄워줘야 함 + 키 새로 부여하면 안됨, 기존 키로 저장해야 함
      -> setInputContent 에서

아쉬운 점

  • 탭 넘길 때 계속 재렌더링 일어나는 것: 처음부터 첫 실행 때 모든 요소들을 렌더링하고 필요할 때만 contentBody에 띄워 주면 좋았을 것 같다.
  • 리스너 콜백으로 화살표 함수 사용할 때 조심: 지금은 어찌어찌 중첩 안 되게 고쳐놨지만 초반에 다 만들어놓고 중첩 사실을 뒤늦게 알아서 힘들었었다... 화살표 함수를 사용할 때 조심해야겠다는 교훈을 얻었다.

참고

0개의 댓글