AI 그리고 Chrome Extension

AI 덕분에 그간 js 기반의 무언가를 하기 껄끄러웠었는데, 이제는 5분컷 작업이 되었다. js를 활용한다면 하고 싶었던 것 중 단연 1위였던 chrome extension을 셀프로 만들어 본다.




실행 방법

실행방법은 Chrome 주소 창에서 chrome://extensions 를 입력해서, 우상단에 있는 개발자 모드를 활성화시키고, 만들어둔 폴더를 좌상단 압축해제된 확장 프로그램 로드 버튼을 통해서 호출하기만 하면 되는 손쉬운 과정이다.




페이지에 있는 테이블 복사하기

테이블을 엑셀로 붙일때, 매번 드래그를 해다가 붙이는 과정을 회사에서 종종 하곤하는데, 이를 만들어보기로 한다. 문제를 어렵게 생각할 필요도 없이, ChatGPT가 이를 만들어내는데까지 3분도 채 걸리지 않은 것 같다.

작동은 아주 잘 된다.

구성은 다음과 같다.

table-area-copy/
├── manifest.json
├── popup.html
├── popup.js
└── content.js




manifest.json

{
  "manifest_version": 3,
  "name": "Table Area Copy",
  "version": "1.0.0",
  "description": "페이지에서 테이블 영역을 선택해 Excel용 데이터로 복사합니다.",
  "permissions": [
    "activeTab",
    "scripting",
    "clipboardWrite"
  ],
  "action": {
    "default_popup": "popup.html",
    "default_title": "테이블 영역 복사"
  }
}



popup.html

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta
    name="viewport"
    content="width=device-width, initial-scale=1.0"
  >

  <title>테이블 복사</title>

  <style>
    * {
      box-sizing: border-box;
    }

    body {
      width: 300px;
      margin: 0;
      padding: 18px;
      font-family:
        -apple-system,
        BlinkMacSystemFont,
        "Segoe UI",
        sans-serif;
      background: #f7f8fa;
      color: #222;
    }

    h1 {
      margin: 0 0 8px;
      font-size: 18px;
    }

    p {
      margin: 0 0 14px;
      color: #666;
      font-size: 13px;
      line-height: 1.5;
    }

    button {
      width: 100%;
      padding: 11px 14px;
      border: 0;
      border-radius: 8px;
      background: #315efb;
      color: white;
      cursor: pointer;
      font-size: 14px;
      font-weight: 600;
    }

    button:hover {
      opacity: 0.9;
    }

    button:disabled {
      cursor: not-allowed;
      opacity: 0.5;
    }

    #message {
      min-height: 20px;
      margin-top: 10px;
      margin-bottom: 0;
      color: #d93025;
    }
  </style>
</head>

<body>
  <h1>테이블 영역 복사</h1>

  <p>
    버튼을 누른 뒤 페이지에서 복사할 테이블 영역을 드래그하세요.
  </p>

  <button id="start-button" type="button">
    영역 선택 시작
  </button>

  <p id="message"></p>

  <script src="popup.js"></script>
</body>
</html>



popup.js

const startButton = document.getElementById("start-button");
const messageElement = document.getElementById("message");

startButton.addEventListener("click", async () => {
  try {
    startButton.disabled = true;
    messageElement.textContent = "";

    const [currentTab] = await chrome.tabs.query({
      active: true,
      currentWindow: true
    });

    if (!currentTab?.id) {
      throw new Error("현재 활성 탭을 찾을 수 없습니다.");
    }

    await chrome.scripting.executeScript({
      target: {
        tabId: currentTab.id
      },
      files: ["content.js"]
    });

    window.close();
  } catch (error) {
    console.error(error);

    messageElement.textContent =
      "이 페이지에서는 영역 선택 기능을 실행할 수 없습니다.";

    startButton.disabled = false;
  }
});



content.js

(() => {
  const INSTANCE_KEY = "__smartAreaCopyInstance";

  if (window[INSTANCE_KEY]?.destroy) {
    window[INSTANCE_KEY].destroy();
  }

  const EXTENSION_ATTRIBUTE = "data-smart-area-copy";
  const MIN_WIDTH = 40;
  const MIN_HEIGHT = 20;

  let hoveredElement = null;
  let candidateElements = [];
  let candidateIndex = 0;
  let destroyed = false;

  const style = document.createElement("style");
  const highlight = document.createElement("div");
  const guide = document.createElement("div");
  const info = document.createElement("div");
  const toast = document.createElement("div");

  style.setAttribute(EXTENSION_ATTRIBUTE, "");
  highlight.setAttribute(EXTENSION_ATTRIBUTE, "");
  guide.setAttribute(EXTENSION_ATTRIBUTE, "");
  info.setAttribute(EXTENSION_ATTRIBUTE, "");
  toast.setAttribute(EXTENSION_ATTRIBUTE, "");

  style.textContent = `
    [${EXTENSION_ATTRIBUTE}].smart-copy-highlight {
      position: fixed;
      z-index: 2147483645;
      display: none;
      border: 2px solid #315efb;
      background: rgba(49, 94, 251, 0.12);
      box-shadow:
        0 0 0 1px rgba(255, 255, 255, 0.9) inset,
        0 4px 16px rgba(0, 0, 0, 0.16);
      pointer-events: none;
      transition:
        left 60ms ease,
        top 60ms ease,
        width 60ms ease,
        height 60ms ease;
    }

    [${EXTENSION_ATTRIBUTE}].smart-copy-guide {
      position: fixed;
      top: 18px;
      left: 50%;
      z-index: 2147483647;
      transform: translateX(-50%);
      max-width: calc(100vw - 32px);
      padding: 10px 16px;
      border-radius: 9px;
      background: rgba(24, 24, 27, 0.94);
      color: white;
      font-family:
        -apple-system,
        BlinkMacSystemFont,
        "Segoe UI",
        sans-serif;
      font-size: 13px;
      line-height: 1.5;
      text-align: center;
      pointer-events: none;
      box-shadow: 0 5px 20px rgba(0, 0, 0, 0.24);
    }

    [${EXTENSION_ATTRIBUTE}].smart-copy-info {
      position: fixed;
      z-index: 2147483647;
      display: none;
      max-width: 360px;
      padding: 6px 9px;
      border-radius: 6px;
      background: #315efb;
      color: white;
      font-family:
        ui-monospace,
        SFMono-Regular,
        Menlo,
        Consolas,
        monospace;
      font-size: 11px;
      line-height: 1.4;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      pointer-events: none;
      box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
    }

    [${EXTENSION_ATTRIBUTE}].smart-copy-toast {
      position: fixed;
      right: 20px;
      bottom: 20px;
      z-index: 2147483647;
      display: none;
      max-width: 380px;
      padding: 12px 16px;
      border-radius: 9px;
      background: rgba(24, 24, 27, 0.95);
      color: white;
      font-family:
        -apple-system,
        BlinkMacSystemFont,
        "Segoe UI",
        sans-serif;
      font-size: 14px;
      line-height: 1.5;
      pointer-events: none;
      box-shadow: 0 5px 20px rgba(0, 0, 0, 0.24);
    }

    html.smart-area-copy-mode,
    html.smart-area-copy-mode * {
      cursor: crosshair !important;
    }
  `;

  highlight.className = "smart-copy-highlight";
  guide.className = "smart-copy-guide";
  info.className = "smart-copy-info";
  toast.className = "smart-copy-toast";

  guide.textContent =
    "복사할 영역을 클릭하세요. ↑·↓로 선택 범위를 변경하고 ESC로 취소할 수 있습니다.";

  document.documentElement.appendChild(style);
  document.body.append(highlight, guide, info, toast);
  document.documentElement.classList.add("smart-area-copy-mode");

  /**
   * 요소가 확장 프로그램이 생성한 UI인지 확인합니다.
   */
  function isExtensionElement(element) {
    return Boolean(
      element?.closest?.(`[${EXTENSION_ATTRIBUTE}]`)
    );
  }

  /**
   * 요소가 화면에 표시되는지 확인합니다.
   */
  function isVisible(element) {
    if (!(element instanceof HTMLElement)) {
      return false;
    }

    const style = window.getComputedStyle(element);
    const rect = element.getBoundingClientRect();

    return (
      style.display !== "none" &&
      style.visibility !== "hidden" &&
      Number(style.opacity) !== 0 &&
      rect.width >= MIN_WIDTH &&
      rect.height >= MIN_HEIGHT
    );
  }

  /**
   * 선택 대상으로 취급할 수 있는 블록인지 확인합니다.
   */
  function isSelectableBlock(element) {
    if (!isVisible(element)) {
      return false;
    }

    if (
      element === document.body ||
      element === document.documentElement ||
      isExtensionElement(element)
    ) {
      return false;
    }

    const rect = element.getBoundingClientRect();

    if (
      rect.width > window.innerWidth * 0.99 &&
      rect.height > window.innerHeight * 0.95
    ) {
      return false;
    }

    const tagName = element.tagName.toLowerCase();
    const role = element.getAttribute("role");

    const preferredTags = new Set([
      "table",
      "section",
      "article",
      "main",
      "ul",
      "ol",
      "dl",
      "div"
    ]);

    const preferredRoles = new Set([
      "table",
      "grid",
      "treegrid",
      "rowgroup",
      "list",
      "listbox"
    ]);

    if (preferredTags.has(tagName)) {
      return true;
    }

    return preferredRoles.has(role);
  }

  /**
   * 요소의 직접 자식 중 화면에 보이는 요소만 반환합니다.
   */
  function getVisibleChildren(element) {
    return [...element.children].filter((child) => {
      return isVisible(child) && !isExtensionElement(child);
    });
  }

  /**
   * 반복되는 행 구조를 가지고 있는지 점수화합니다.
   */
  function getRepeatStructureScore(element) {
    const children = getVisibleChildren(element);

    if (children.length < 2) {
      return 0;
    }

    const signatures = children.map((child) => {
      const visibleChildCount = getVisibleChildren(child).length;

      return [
        child.tagName,
        child.getAttribute("role") || "",
        visibleChildCount
      ].join(":");
    });

    const signatureCounts = new Map();

    for (const signature of signatures) {
      signatureCounts.set(
        signature,
        (signatureCounts.get(signature) || 0) + 1
      );
    }

    const largestGroup = Math.max(...signatureCounts.values());

    return largestGroup / children.length;
  }

  /**
   * 선택 후보의 우선순위를 계산합니다.
   */
  function calculateCandidateScore(element, depth) {
    const tagName = element.tagName.toLowerCase();
    const role = element.getAttribute("role");
    const children = getVisibleChildren(element);
    const repeatScore = getRepeatStructureScore(element);

    let score = 0;

    if (tagName === "table") {
      score += 100;
    }

    if (["table", "grid", "treegrid"].includes(role)) {
      score += 90;
    }

    if (["ul", "ol", "dl"].includes(tagName)) {
      score += 50;
    }

    if (["section", "article", "main"].includes(tagName)) {
      score += 30;
    }

    if (tagName === "div") {
      score += 15;
    }

    if (children.length >= 2) {
      score += Math.min(children.length, 10) * 2;
    }

    score += repeatScore * 50;

    // 마우스와 가까운 요소를 조금 더 우선합니다.
    score -= depth * 3;

    return score;
  }

  /**
   * 마우스 아래 요소에서 선택 가능한 부모 후보들을 구합니다.
   */
  function buildCandidates(target) {
    const candidates = [];
    let current = target;
    let depth = 0;

    while (
      current &&
      current instanceof HTMLElement &&
      current !== document.body &&
      depth < 10
    ) {
      if (isSelectableBlock(current)) {
        candidates.push({
          element: current,
          score: calculateCandidateScore(current, depth),
          depth
        });
      }

      current = current.parentElement;
      depth += 1;
    }

    // 가까운 요소를 기본으로 유지하되,
    // 데이터 구조가 강한 table/grid 요소는 우선합니다.
    return candidates
      .sort((a, b) => {
        const scoreDifference = b.score - a.score;

        if (Math.abs(scoreDifference) >= 35) {
          return scoreDifference;
        }

        return a.depth - b.depth;
      })
      .map((candidate) => candidate.element);
  }

  function describeElement(element) {
    const tag = element.tagName.toLowerCase();
    const id = element.id ? `#${element.id}` : "";

    const classNames = [...element.classList]
      .filter((className) => !className.startsWith("smart-"))
      .slice(0, 3)
      .map((className) => `.${className}`)
      .join("");

    const role = element.getAttribute("role");
    const roleText = role ? ` [role="${role}"]` : "";

    return `${tag}${id}${classNames}${roleText}`;
  }

  /**
   * 현재 후보 요소 위치로 하이라이트를 이동합니다.
   */
  function renderHighlight(element) {
    if (!element || !element.isConnected) {
      highlight.style.display = "none";
      info.style.display = "none";
      return;
    }

    const rect = element.getBoundingClientRect();

    highlight.style.display = "block";
    highlight.style.left = `${rect.left}px`;
    highlight.style.top = `${rect.top}px`;
    highlight.style.width = `${rect.width}px`;
    highlight.style.height = `${rect.height}px`;

    info.textContent = describeElement(element);
    info.style.display = "block";

    const preferredTop = rect.top - 27;
    const top =
      preferredTop >= 4
        ? preferredTop
        : Math.min(rect.bottom + 5, window.innerHeight - 32);

    info.style.left = `${Math.max(4, rect.left)}px`;
    info.style.top = `${top}px`;
    info.style.maxWidth = `${Math.max(
      100,
      Math.min(rect.width, 360)
    )}px`;
  }

  /**
   * 셀 텍스트를 Excel에 안전한 한 줄 문자열로 정리합니다.
   */
  function normalizeCellText(text) {
    return String(text || "")
      .replace(/\u00a0/g, " ")
      .replace(/\t/g, " ")
      .replace(/\r?\n+/g, " ")
      .replace(/\s+/g, " ")
      .trim();
  }

  function getElementText(element) {
    const input = element.matches(
      "input:not([type='hidden']), textarea, select"
    )
      ? element
      : element.querySelector(
          "input:not([type='hidden']), textarea, select"
        );

    if (input instanceof HTMLSelectElement) {
      return normalizeCellText(
        input.selectedOptions[0]?.textContent || input.value
      );
    }

    if (
      input instanceof HTMLInputElement ||
      input instanceof HTMLTextAreaElement
    ) {
      return normalizeCellText(input.value);
    }

    return normalizeCellText(element.innerText);
  }

  /**
   * 표준 HTML 테이블을 colspan/rowspan까지 고려해 배열로 변환합니다.
   */
  function extractNativeTable(table) {
    const matrix = [];
    const rows = [...table.rows];

    rows.forEach((row, rowIndex) => {
      matrix[rowIndex] ||= [];

      let columnIndex = 0;

      for (const cell of [...row.cells]) {
        while (matrix[rowIndex][columnIndex] !== undefined) {
          columnIndex += 1;
        }

        const text = getElementText(cell);
        const rowSpan = Math.max(1, cell.rowSpan || 1);
        const colSpan = Math.max(1, cell.colSpan || 1);

        for (
          let rowOffset = 0;
          rowOffset < rowSpan;
          rowOffset += 1
        ) {
          const targetRowIndex = rowIndex + rowOffset;
          matrix[targetRowIndex] ||= [];

          for (
            let columnOffset = 0;
            columnOffset < colSpan;
            columnOffset += 1
          ) {
            const targetColumnIndex = columnIndex + columnOffset;

            matrix[targetRowIndex][targetColumnIndex] =
              rowOffset === 0 && columnOffset === 0 ? text : "";
          }
        }

        columnIndex += colSpan;
      }
    });

    return matrix;
  }

  /**
   * ARIA grid/table 구조를 배열로 변환합니다.
   */
  function extractAriaGrid(container) {
    const rows = [
      ...container.querySelectorAll(
        ':scope [role="row"]'
      )
    ].filter(isVisible);

    return rows
      .map((row) => {
        const cells = [
          ...row.querySelectorAll(
            ':scope > [role="cell"], ' +
            ':scope > [role="gridcell"], ' +
            ':scope > [role="columnheader"], ' +
            ':scope > [role="rowheader"]'
          )
        ].filter(isVisible);

        return cells.map(getElementText);
      })
      .filter((row) => row.length > 0);
  }

  /**
   * 자식 요소의 화면상 X 위치를 기준으로 셀 순서를 정렬합니다.
   */
  function sortElementsByPosition(elements) {
    return [...elements].sort((a, b) => {
      const rectA = a.getBoundingClientRect();
      const rectB = b.getBoundingClientRect();

      const rowDifference = rectA.top - rectB.top;

      if (Math.abs(rowDifference) > 5) {
        return rowDifference;
      }

      return rectA.left - rectB.left;
    });
  }

  /**
   * 반복되는 div 행을 찾습니다.
   */
  function findGenericRows(container) {
    const directChildren = getVisibleChildren(container);

    const directRowCandidates = directChildren.filter((child) => {
      return getVisibleChildren(child).length >= 2;
    });

    if (directRowCandidates.length >= 2) {
      return directRowCandidates;
    }

    const descendants = [
      ...container.querySelectorAll(
        ':scope > div > div, ' +
        ':scope > section > div, ' +
        ':scope [role="row"]'
      )
    ].filter((element) => {
      return (
        isVisible(element) &&
        getVisibleChildren(element).length >= 2
      );
    });

    if (descendants.length < 2) {
      return [];
    }

    // 같은 부모 아래 반복되는 후보 그룹 중 가장 큰 것을 선택합니다.
    const groups = new Map();

    for (const element of descendants) {
      const parent = element.parentElement;

      if (!parent) {
        continue;
      }

      if (!groups.has(parent)) {
        groups.set(parent, []);
      }

      groups.get(parent).push(element);
    }

    return [...groups.values()]
      .sort((a, b) => b.length - a.length)[0] || [];
  }

  /**
   * div 기반 행에서 셀 후보를 가져옵니다.
   */
  function extractCellsFromGenericRow(row) {
    let cells = getVisibleChildren(row);

    if (cells.length <= 1) {
      return [getElementText(row)];
    }

    // wrapper가 하나 더 있는 경우 한 단계 내려갑니다.
    if (
      cells.length === 2 &&
      cells.some((cell) => getVisibleChildren(cell).length >= 2)
    ) {
      const expanded = cells.flatMap((cell) => {
        const children = getVisibleChildren(cell);
        return children.length >= 2 ? children : [cell];
      });

      if (expanded.length > cells.length) {
        cells = expanded;
      }
    }

    return sortElementsByPosition(cells)
      .map(getElementText)
      .filter((text, index, array) => {
        // 빈 셀은 중간 열 정렬을 위해 유지하되,
        // 모든 셀이 비는 상황만 방지합니다.
        return text !== "" || array.some(Boolean);
      });
  }

  /**
   * div, section, list 형태의 반복 구조를 배열로 변환합니다.
   */
  function extractGenericGrid(container) {
    if (
      container.matches("ul, ol") &&
      getVisibleChildren(container).length > 0
    ) {
      const items = getVisibleChildren(container);

      return items.map((item) => {
        const children = getVisibleChildren(item);

        if (children.length >= 2) {
          return sortElementsByPosition(children).map(getElementText);
        }

        return [getElementText(item)];
      });
    }

    const rows = findGenericRows(container);

    if (rows.length >= 2) {
      return sortElementsByPosition(rows)
        .map(extractCellsFromGenericRow)
        .filter((row) => row.some(Boolean));
    }

    const children = getVisibleChildren(container);

    if (children.length >= 2) {
      return [
        sortElementsByPosition(children).map(getElementText)
      ];
    }

    const text = getElementText(container);

    return text ? [[text]] : [];
  }

  /**
   * 선택된 요소 유형에 맞는 추출 방식을 결정합니다.
   */
  function extractData(element) {
    if (element instanceof HTMLTableElement) {
      return extractNativeTable(element);
    }

    const nestedTable = element.querySelector(":scope > table");

    if (nestedTable instanceof HTMLTableElement) {
      return extractNativeTable(nestedTable);
    }

    const role = element.getAttribute("role");

    if (
      ["table", "grid", "treegrid", "rowgroup"].includes(role) ||
      element.querySelector('[role="row"]')
    ) {
      const ariaResult = extractAriaGrid(element);

      if (ariaResult.length > 0) {
        return ariaResult;
      }
    }

    return extractGenericGrid(element);
  }

  /**
   * 행마다 열 개수가 다른 경우 빈 문자열로 보정합니다.
   */
  function normalizeMatrix(matrix) {
    const validRows = matrix.filter((row) => {
      return Array.isArray(row) && row.some((cell) => cell !== "");
    });

    if (validRows.length === 0) {
      return [];
    }

    const maxColumnCount = Math.max(
      ...validRows.map((row) => row.length)
    );

    return validRows.map((row) => {
      return [
        ...row,
        ...Array(Math.max(0, maxColumnCount - row.length)).fill("")
      ];
    });
  }

  function matrixToTsv(matrix) {
    return matrix
      .map((row) => {
        return row
          .map((cell) => normalizeCellText(cell))
          .join("\t");
      })
      .join("\n");
  }

  async function copyToClipboard(text) {
    try {
      await navigator.clipboard.writeText(text);
      return;
    } catch (error) {
      console.warn("Clipboard API 복사 실패:", error);
    }

    const textarea = document.createElement("textarea");

    textarea.value = text;
    textarea.setAttribute("readonly", "");
    textarea.style.position = "fixed";
    textarea.style.left = "-9999px";
    textarea.style.top = "-9999px";

    document.body.appendChild(textarea);
    textarea.select();

    const copied = document.execCommand("copy");

    textarea.remove();

    if (!copied) {
      throw new Error("클립보드 복사에 실패했습니다.");
    }
  }

  function showToast(message, duration = 2400) {
    toast.textContent = message;
    toast.style.display = "block";

    window.setTimeout(() => {
      toast.style.display = "none";
    }, duration);
  }

  function handleMouseMove(event) {
    if (destroyed || isExtensionElement(event.target)) {
      return;
    }

    const target = document.elementFromPoint(
      event.clientX,
      event.clientY
    );

    if (
      !(target instanceof HTMLElement) ||
      isExtensionElement(target)
    ) {
      return;
    }

    if (target === hoveredElement) {
      return;
    }

    hoveredElement = target;
    candidateElements = buildCandidates(target);
    candidateIndex = 0;

    renderHighlight(candidateElements[candidateIndex]);
  }

  async function handleClick(event) {
    if (destroyed || isExtensionElement(event.target)) {
      return;
    }

    const selectedElement = candidateElements[candidateIndex];

    if (!selectedElement) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();
    event.stopImmediatePropagation();

    try {
      const rawMatrix = extractData(selectedElement);
      const matrix = normalizeMatrix(rawMatrix);

      if (matrix.length === 0) {
        showToast(
          "선택한 영역에서 복사할 행이나 셀을 찾지 못했습니다."
        );
        return;
      }

      const tsv = matrixToTsv(matrix);

      if (!tsv.trim()) {
        showToast("복사할 텍스트가 없습니다.");
        return;
      }

      await copyToClipboard(tsv);

      const rowCount = matrix.length;
      const columnCount = Math.max(
        ...matrix.map((row) => row.length)
      );

      destroy();

      showTemporaryResultToast(
        `${rowCount}행 × ${columnCount}열을 복사했습니다. Excel에 붙여넣으세요.`
      );
    } catch (error) {
      console.error("영역 복사 실패:", error);

      showToast(
        error instanceof Error
          ? error.message
          : "데이터 복사에 실패했습니다."
      );
    }
  }

  function handleKeyDown(event) {
    if (event.key === "Escape") {
      event.preventDefault();
      destroy();
      return;
    }

    if (
      event.key !== "ArrowUp" &&
      event.key !== "ArrowDown"
    ) {
      return;
    }

    if (candidateElements.length === 0) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    if (event.key === "ArrowUp") {
      candidateIndex = Math.min(
        candidateIndex + 1,
        candidateElements.length - 1
      );
    } else {
      candidateIndex = Math.max(candidateIndex - 1, 0);
    }

    renderHighlight(candidateElements[candidateIndex]);
  }

  function handleScroll() {
    const selectedElement = candidateElements[candidateIndex];

    if (selectedElement) {
      renderHighlight(selectedElement);
    }
  }

  function showTemporaryResultToast(message) {
    const resultToast = document.createElement("div");

    resultToast.setAttribute(EXTENSION_ATTRIBUTE, "");
    resultToast.className = "smart-copy-toast";
    resultToast.textContent = message;
    resultToast.style.display = "block";

    document.body.appendChild(resultToast);

    window.setTimeout(() => {
      resultToast.remove();
    }, 2600);
  }

  function destroy() {
    if (destroyed) {
      return;
    }

    destroyed = true;

    document.removeEventListener("mousemove", handleMouseMove, true);
    document.removeEventListener("click", handleClick, true);
    document.removeEventListener("keydown", handleKeyDown, true);
    window.removeEventListener("scroll", handleScroll, true);
    window.removeEventListener("resize", handleScroll);

    document.documentElement.classList.remove(
      "smart-area-copy-mode"
    );

    style.remove();
    highlight.remove();
    guide.remove();
    info.remove();
    toast.remove();

    delete window[INSTANCE_KEY];
  }

  document.addEventListener("mousemove", handleMouseMove, true);
  document.addEventListener("click", handleClick, true);
  document.addEventListener("keydown", handleKeyDown, true);
  window.addEventListener("scroll", handleScroll, true);
  window.addEventListener("resize", handleScroll);

  window[INSTANCE_KEY] = {
    destroy
  };
})();



끝.