capototototo화면 캡처 도구

Posted by DavidEugen on September 28, 2026 · 21 mins read
[manifest.json]=========
{
  "manifest_version": 3,
  "name": "화면 캡처 도구",
  "description": "URL이 바뀌지 않는 JSP 업무 화면의 메뉴 경로를 추적하고 지정한 이름으로 캡처합니다.",
  "version": "1.1.0",
  "minimum_chrome_version": "114",
  "permissions": [
    "activeTab",
    "downloads",
    "storage",
    "tabs"
  ],
  "host_permissions": [
    "<all_urls>"
  ],
  "background": {
    "service_worker": "service-worker.js"
  },
  "action": {
    "default_title": "화면 캡처 도구 열기",
    "default_popup": "popup.html"
  },
  "content_scripts": [
    {
      "matches": [
        "http://*/*",
        "https://*/*"
      ],
      "js": [
        "content.js"
      ],
      "all_frames": true,
      "match_about_blank": true,
      "run_at": "document_idle"
    }
  ],
  "commands": {
    "capture-visible": {
      "suggested_key": {
        "default": "Ctrl+Shift+7"
      },
      "description": "현재 탭의 보이는 화면 캡처"
    }
  }
}

[service-worker.js]=========
"use strict";

const STATE_KEY = "jspCaptureToolState";

function compactText(value) {
  return String(value ?? "").replace(/\s+/g, " ").trim();
}

function sanitizeFilenamePart(value, fallback) {
  const cleaned = compactText(value)
    .replace(/[<>:"/\\|?*\u0000-\u001F]/g, "-")
    .replace(/_+/g, "-")
    .replace(/[. ]+$/g, "")
    .slice(0, 80);
  return cleaned || fallback;
}

function sanitizeFolder(value) {
  const parts = String(value ?? "")
    .replace(/\\/g, "/")
    .split("/")
    .map((part) => sanitizeFilenamePart(part, ""))
    .filter((part) => part && part !== "." && part !== "..");
  return parts.join("/") || "화면캡처";
}

function buildDownloadFilename(values) {
  const folder = sanitizeFolder(values.folder);
  const parts = [
    sanitizeFilenamePart(values.gnb, "GNB"),
    sanitizeFilenamePart(values.subGnb, "SubGNB"),
    sanitizeFilenamePart(values.lnb, "LNB"),
    sanitizeFilenamePart(values.subLnb, "SubLNB"),
    sanitizeFilenamePart(values.action, "액션")
  ];
  return `${folder}/${parts.join("_")}.png`;
}

async function getActiveTab() {
  const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
  const tab = tabs[0];
  if (!tab?.id || !/^https?:/i.test(tab.url || "")) {
    throw new Error("캡처할 웹 페이지 탭을 먼저 선택해 주세요.");
  }
  return tab;
}

async function captureVisible(values) {
  const tab = await getActiveTab();
  const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
    format: "png"
  });
  const filename = buildDownloadFilename(values);
  const downloadId = await chrome.downloads.download({
    url: dataUrl,
    filename,
    conflictAction: "uniquify",
    saveAs: false
  });
  return { downloadId, filename };
}

async function valuesForShortcut() {
  const stored = await chrome.storage.local.get(STATE_KEY);
  const state = stored[STATE_KEY] || {};
  const tab = await getActiveTab();
  let siteKey = "default";
  try {
    siteKey = new URL(tab.url).origin;
  } catch (error) {
    // 기본 프로필을 사용합니다.
  }
  const menuValues = state.profiles?.[siteKey]?.values || {};
  const values = {
    folder: state.folder || "화면캡처",
    gnb: menuValues.gnb || "",
    subGnb: menuValues.subGnb || "",
    lnb: menuValues.lnb || "",
    subLnb: menuValues.subLnb || "",
    action: state.action || "조회"
  };
  if ([values.gnb, values.subGnb, values.lnb, values.subLnb].some((value) => !compactText(value))) {
    throw new Error("확장 팝업에서 메뉴 4칸을 먼저 입력해 주세요.");
  }
  return values;
}

chrome.commands.onCommand.addListener(async (command) => {
  if (command !== "capture-visible") {
    return;
  }
  try {
    await captureVisible(await valuesForShortcut());
  } catch (error) {
    console.warn("단축키 캡처 실패:", error);
  }
});

async function cacheMenuState(tabId, message) {
  const key = `menuState:${tabId}`;
  const stored = await chrome.storage.session.get(key);
  const previous = stored[key] || { values: {} };
  await chrome.storage.session.set({
    [key]: {
      values: { ...previous.values, ...(message.values || {}) },
      pageTitle: message.pageTitle || previous.pageTitle || "",
      updatedAt: Date.now()
    }
  });
}

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type === "MENU_STATE" && sender.tab?.id) {
    cacheMenuState(sender.tab.id, message).catch(console.warn);
    return false;
  }

  if (message?.type === "GET_TAB_MENU_STATE") {
    const key = `menuState:${message.tabId}`;
    chrome.storage.session.get(key)
      .then((stored) => sendResponse({ ok: true, data: stored[key] || { values: {} } }))
      .catch((error) => sendResponse({ ok: false, error: error?.message || "메뉴 상태를 불러오지 못했습니다." }));
    return true;
  }

  if (message?.type === "CAPTURE_VISIBLE") {
    captureVisible(message.values || {})
      .then((data) => sendResponse({ ok: true, data }))
      .catch((error) => sendResponse({
        ok: false,
        error: error?.message || "화면 캡처에 실패했습니다."
      }));
    return true;
  }

  return false;
});

[content.js]=========
(function initializeJspCaptureObserver() {
  "use strict";

  if (globalThis.__JSP_CAPTURE_OBSERVER_LOADED__) {
    return;
  }
  globalThis.__JSP_CAPTURE_OBSERVER_LOADED__ = true;

  const ACTIVE_SELECTOR = [
    "[aria-current]:not([aria-current='false'])",
    "[aria-selected='true']",
    ".active",
    ".current",
    ".selected",
    ".on",
    ".is-active",
    ".is-current"
  ].join(",");

  const LEVELS = [
    {
      key: "gnb",
      selectors: [
        "[data-capture-menu-level='gnb']",
        "#gnb",
        ".gnb",
        "[class~='gnb' i]",
        "header nav"
      ]
    },
    {
      key: "subGnb",
      selectors: [
        "[data-capture-menu-level='subGnb']",
        "#subGnb",
        "#sub-gnb",
        ".subGnb",
        ".sub-gnb",
        "[class*='subgnb' i]",
        "[class*='sub-gnb' i]"
      ]
    },
    {
      key: "lnb",
      selectors: [
        "[data-capture-menu-level='lnb']",
        "#lnb",
        ".lnb",
        "[class~='lnb' i]",
        "aside nav"
      ]
    },
    {
      key: "subLnb",
      selectors: [
        "[data-capture-menu-level='subLnb']",
        "#subLnb",
        "#sub-lnb",
        ".subLnb",
        ".sub-lnb",
        "[class*='sublnb' i]",
        "[class*='sub-lnb' i]"
      ]
    }
  ];

  const runtimeValues = {
    gnb: "",
    subGnb: "",
    lnb: "",
    subLnb: ""
  };
  let scanTimer = 0;

  function compactText(value, maximum = 100) {
    const text = String(value || "").replace(/\s+/g, " ").trim();
    return text.length > maximum ? text.slice(0, maximum) : text;
  }

  function isVisible(element) {
    if (!element || element.hidden || element.getAttribute("aria-hidden") === "true") {
      return false;
    }
    const style = getComputedStyle(element);
    if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) {
      return false;
    }
    const rect = element.getBoundingClientRect();
    return rect.width > 0 && rect.height > 0;
  }

  function labelFor(element) {
    if (!element) {
      return "";
    }
    const explicit = element.getAttribute("data-capture-label")
      || element.getAttribute("aria-label")
      || element.getAttribute("title");
    if (compactText(explicit)) {
      return compactText(explicit);
    }

    const directLabel = element.matches("a,button,[role='menuitem']")
      ? element
      : element.querySelector(":scope > a, :scope > button, :scope > [role='menuitem'], :scope > span");
    const source = directLabel || element;
    const clone = source.cloneNode(true);
    clone.querySelectorAll("ul,ol,nav,[role='menu'],script,style").forEach((child) => child.remove());
    return compactText(clone.textContent);
  }

  function firstVisible(selector, root = document) {
    try {
      return Array.from(root.querySelectorAll(selector)).find(isVisible) || null;
    } catch (error) {
      return null;
    }
  }

  function findContainer(level) {
    for (const selector of level.selectors) {
      const match = firstVisible(selector);
      if (match) {
        return match;
      }
    }
    return null;
  }

  function activeLabel(container) {
    if (!container) {
      return "";
    }
    let candidates = [];
    try {
      candidates = Array.from(container.querySelectorAll(ACTIVE_SELECTOR)).filter(isVisible);
    } catch (error) {
      return "";
    }
    for (let index = candidates.length - 1; index >= 0; index -= 1) {
      const text = labelFor(candidates[index]);
      if (text) {
        return text;
      }
    }
    if (container.matches(ACTIVE_SELECTOR)) {
      return labelFor(container);
    }
    return "";
  }

  function detectValues() {
    const detected = {};
    for (const level of LEVELS) {
      const value = activeLabel(findContainer(level));
      if (value) {
        detected[level.key] = value;
      }
    }
    return detected;
  }

  function emit(values, source) {
    if (!Object.keys(values).length) {
      return;
    }
    chrome.runtime.sendMessage({
      type: "MENU_STATE",
      values,
      source,
      isTopFrame: window === window.top,
      pageTitle: document.title
    }).catch(() => undefined);
  }

  function scan(source = "mutation") {
    const detected = detectValues();
    const changed = {};
    for (const level of LEVELS) {
      const value = detected[level.key];
      if (value && value !== runtimeValues[level.key]) {
        runtimeValues[level.key] = value;
        changed[level.key] = value;
      }
    }
    emit(changed, source);
    return { ...runtimeValues };
  }

  function levelForElement(element) {
    const reversed = [...LEVELS].reverse();
    for (const level of reversed) {
      for (const selector of level.selectors) {
        try {
          if (element.matches(selector) || element.closest(selector)) {
            return level.key;
          }
        } catch (error) {
          // 유효하지 않은 사이트 선택자는 건너뜁니다.
        }
      }
    }
    return "";
  }

  document.addEventListener("click", (event) => {
    const path = typeof event.composedPath === "function" ? event.composedPath() : [event.target];
    const clicked = path.find((item) => item instanceof Element);
    if (!clicked) {
      return;
    }
    const interactive = clicked.closest("a,button,li,[role='menuitem']") || clicked;
    const level = levelForElement(interactive);
    const value = labelFor(interactive);
    if (level && value) {
      runtimeValues[level] = value;
      emit({ [level]: value }, "click");
    }
    window.clearTimeout(scanTimer);
    scanTimer = window.setTimeout(() => scan("after-click"), 250);
  }, true);

  chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
    if (message?.type !== "DETECT_MENUS") {
      return false;
    }
    sendResponse({
      ok: true,
      values: scan("manual"),
      pageTitle: document.title
    });
    return false;
  });

  const observer = new MutationObserver(() => {
    window.clearTimeout(scanTimer);
    scanTimer = window.setTimeout(() => scan("mutation"), 180);
  });

  observer.observe(document.documentElement, {
    subtree: true,
    childList: true,
    attributes: true,
    attributeFilter: ["class", "aria-current", "aria-selected", "hidden", "style"]
  });

  scan("initial");
  window.setInterval(() => scan("interval"), 1500);
})();

[popup.html]=========
<!doctype html>
<html lang="ko">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>JSP 화면 캡처</title>
    <link rel="stylesheet" href="popup.css">
  </head>
  <body>
    <header class="app-header">
      <div>
        <p class="eyebrow">EDGE EXTENSION</p>
        <h1>JSP 화면 캡처</h1>
      </div>
      <span id="connectionBadge" class="badge">확인 중</span>
    </header>

    <main>
      <form id="captureForm" novalidate>
        <section class="card" aria-labelledby="pathTitle">
          <div class="section-title">
            <span class="step-number">1</span>
            <div>
              <h2 id="pathTitle">저장 경로</h2>
              <p>Edge의 다운로드 폴더 아래 경로입니다.</p>
            </div>
          </div>

          <label class="field">
            <span>저장 경로</span>
            <input
              id="folderInput"
              name="folder"
              type="text"
              maxlength="180"
              placeholder="예: 화면캡처/회원관리"
              spellcheck="false"
              required
            >
          </label>
        </section>

        <section class="card" aria-labelledby="menuTitle">
          <div class="section-title">
            <span class="step-number">2</span>
            <div>
              <h2 id="menuTitle">메뉴 경로</h2>
              <p>감지된 메뉴를 확인하거나 직접 입력하세요.</p>
            </div>
          </div>

          <div class="menu-grid">
            <label class="field">
              <span>GNB</span>
              <input id="gnbInput" name="gnb" type="text" maxlength="100" list="gnbHistory" placeholder="대메뉴" autocomplete="off" required>
              <datalist id="gnbHistory"></datalist>
            </label>

            <label class="field">
              <span>SubGNB</span>
              <input id="subGnbInput" name="subGnb" type="text" maxlength="100" list="subGnbHistory" placeholder="중메뉴" autocomplete="off" required>
              <datalist id="subGnbHistory"></datalist>
            </label>

            <label class="field">
              <span>LNB</span>
              <input id="lnbInput" name="lnb" type="text" maxlength="100" list="lnbHistory" placeholder="좌측 메뉴" autocomplete="off" required>
              <datalist id="lnbHistory"></datalist>
            </label>

            <label class="field">
              <span>SubLNB</span>
              <input id="subLnbInput" name="subLnb" type="text" maxlength="100" list="subLnbHistory" placeholder="좌측 하위 메뉴" autocomplete="off" required>
              <datalist id="subLnbHistory"></datalist>
            </label>
          </div>

          <div class="detect-row">
            <span id="detectMessage">현재 화면의 메뉴 상태를 확인합니다.</span>
            <button id="detectButton" class="text-button" type="button">다시 감지</button>
          </div>
        </section>

        <section class="card" aria-labelledby="actionTitle">
          <div class="section-title">
            <span class="step-number">3</span>
            <div>
              <h2 id="actionTitle">액션</h2>
              <p>화면 상태를 설명하는 마지막 이름입니다.</p>
            </div>
          </div>

          <label class="field">
            <span>액션명</span>
            <input id="actionInput" name="action" type="text" maxlength="100" list="actionSuggestions" placeholder="예: 조회, 저장, 수정, 삭제" autocomplete="off" required>
            <datalist id="actionSuggestions">
              <option value="조회"></option>
              <option value="등록"></option>
              <option value="저장"></option>
              <option value="수정"></option>
              <option value="삭제"></option>
              <option value="검색"></option>
              <option value="상세"></option>
              <option value="다운로드"></option>
              <option value="인쇄"></option>
            </datalist>
          </label>
        </section>

        <section class="preview-card" aria-live="polite">
          <span>저장될 파일</span>
          <strong id="filenamePreview">입력값을 작성해 주세요.</strong>
        </section>

        <button id="captureButton" class="capture-button" type="submit">
          <span class="camera-icon" aria-hidden="true"></span>
          <span>
            <strong>현재 화면 캡처</strong>
            <small>보이는 웹 화면을 PNG로 저장</small>
          </span>
        </button>

        <p id="statusMessage" class="status-message" role="status"></p>
      </form>
    </main>

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

[popup.css]=========
:root {
  color-scheme: light;
  font-family: "Pretendard", "Noto Sans KR", "Malgun Gothic", system-ui, sans-serif;
  color: #172033;
  background: #f3f6fb;
  font-synthesis: none;
}

* {
  box-sizing: border-box;
}

html,
body {
  width: 400px;
}

body {
  margin: 0;
  min-width: 400px;
  min-height: 100vh;
  background:
    radial-gradient(circle at 100% 0, rgba(55, 107, 246, 0.11), transparent 240px),
    #f3f6fb;
}

button,
input {
  font: inherit;
}

.app-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 22px 20px 17px;
  border-bottom: 1px solid #dfe5ef;
  background: rgba(255, 255, 255, 0.82);
  backdrop-filter: blur(12px);
  position: sticky;
  top: 0;
  z-index: 10;
}

.eyebrow {
  margin: 0 0 3px;
  color: #5f6d86;
  font-size: 10px;
  font-weight: 800;
  letter-spacing: 0.12em;
}

h1 {
  margin: 0;
  color: #111a2c;
  font-size: 21px;
  letter-spacing: -0.04em;
}

.badge {
  flex: none;
  padding: 6px 9px;
  border: 1px solid #d7deea;
  border-radius: 999px;
  color: #68748a;
  background: #f7f9fc;
  font-size: 11px;
  font-weight: 700;
}

.badge.connected {
  border-color: #b7e4d1;
  color: #087b54;
  background: #eaf9f2;
}

main {
  padding: 14px;
}

.card {
  margin-bottom: 10px;
  padding: 16px;
  border: 1px solid #e0e6ef;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 4px 16px rgba(39, 54, 84, 0.045);
}

.section-title {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  margin-bottom: 15px;
}

.step-number {
  display: grid;
  place-items: center;
  width: 25px;
  height: 25px;
  flex: none;
  border-radius: 8px;
  color: #fff;
  background: #376bf6;
  font-size: 12px;
  font-weight: 800;
  box-shadow: 0 4px 10px rgba(55, 107, 246, 0.25);
}

.section-title h2 {
  margin: 1px 0 3px;
  color: #1b2538;
  font-size: 14px;
  letter-spacing: -0.025em;
}

.section-title p {
  margin: 0;
  color: #7a869a;
  font-size: 11px;
  line-height: 1.45;
}

.field {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.field > span {
  color: #4b5870;
  font-size: 11px;
  font-weight: 750;
}

.field input {
  width: 100%;
  height: 39px;
  padding: 0 11px;
  border: 1px solid #d7deea;
  border-radius: 9px;
  outline: none;
  color: #182238;
  background: #fbfcfe;
  font-size: 13px;
  transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease;
}

.field input:hover {
  border-color: #b9c5d8;
  background: #fff;
}

.field input:focus {
  border-color: #376bf6;
  background: #fff;
  box-shadow: 0 0 0 3px rgba(55, 107, 246, 0.12);
}

.field input:invalid:not(:placeholder-shown) {
  border-color: #db5664;
}

.menu-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 12px 10px;
}

.detect-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-top: 13px;
  padding-top: 11px;
  border-top: 1px solid #edf0f5;
  color: #7a869a;
  font-size: 10px;
  line-height: 1.45;
}

.text-button {
  flex: none;
  padding: 5px 7px;
  border: 0;
  color: #315fd7;
  background: transparent;
  font-size: 11px;
  font-weight: 750;
  cursor: pointer;
}

.text-button:hover {
  text-decoration: underline;
}

.preview-card {
  margin: 13px 2px 10px;
  padding: 11px 12px;
  border: 1px dashed #c8d2e2;
  border-radius: 10px;
  background: rgba(255, 255, 255, 0.68);
}

.preview-card span {
  display: block;
  margin-bottom: 4px;
  color: #7a869a;
  font-size: 10px;
  font-weight: 700;
}

.preview-card strong {
  display: block;
  overflow-wrap: anywhere;
  color: #40506a;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 10px;
  font-weight: 600;
  line-height: 1.45;
}

.capture-button {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  width: 100%;
  min-height: 58px;
  padding: 10px 16px;
  border: 0;
  border-radius: 13px;
  color: #fff;
  background: linear-gradient(135deg, #376bf6, #274fc3);
  box-shadow: 0 8px 20px rgba(45, 91, 218, 0.27);
  cursor: pointer;
  transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
}

.capture-button:hover:not(:disabled) {
  transform: translateY(-1px);
  box-shadow: 0 10px 24px rgba(45, 91, 218, 0.34);
}

.capture-button:active:not(:disabled) {
  transform: translateY(0);
}

.capture-button:disabled {
  cursor: wait;
  opacity: 0.72;
}

.capture-button > span:last-child {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
}

.capture-button strong {
  font-size: 14px;
}

.capture-button small {
  color: rgba(255, 255, 255, 0.76);
  font-size: 10px;
}

.camera-icon {
  position: relative;
  width: 23px;
  height: 17px;
  border: 2px solid currentColor;
  border-radius: 5px;
}

.camera-icon::before {
  content: "";
  position: absolute;
  top: -6px;
  left: 5px;
  width: 8px;
  height: 5px;
  border-radius: 3px 3px 0 0;
  background: currentColor;
}

.camera-icon::after {
  content: "";
  position: absolute;
  top: 3px;
  left: 7px;
  width: 5px;
  height: 5px;
  border: 2px solid currentColor;
  border-radius: 50%;
}

.capture-button.busy .camera-icon {
  border-radius: 50%;
  border-right-color: transparent;
  animation: spin 800ms linear infinite;
}

.capture-button.busy .camera-icon::before,
.capture-button.busy .camera-icon::after {
  display: none;
}

.status-message {
  min-height: 19px;
  margin: 9px 2px 0;
  color: #6f7b90;
  font-size: 10px;
  line-height: 1.5;
  overflow-wrap: anywhere;
}

.status-message.success {
  color: #087b54;
}

.status-message.error {
  color: #c43749;
}

.status-message.working {
  color: #315fd7;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

[popup.js]=========
"use strict";

// 툴바 팝업이 닫혀도 모든 입력값은 chrome.storage.local에 유지됩니다.

const STATE_KEY = "jspCaptureToolState";
const MENU_KEYS = ["gnb", "subGnb", "lnb", "subLnb"];
const HISTORY_LIMIT = 40;

const elements = {
  form: document.querySelector("#captureForm"),
  folder: document.querySelector("#folderInput"),
  gnb: document.querySelector("#gnbInput"),
  subGnb: document.querySelector("#subGnbInput"),
  lnb: document.querySelector("#lnbInput"),
  subLnb: document.querySelector("#subLnbInput"),
  action: document.querySelector("#actionInput"),
  captureButton: document.querySelector("#captureButton"),
  detectButton: document.querySelector("#detectButton"),
  filenamePreview: document.querySelector("#filenamePreview"),
  detectMessage: document.querySelector("#detectMessage"),
  statusMessage: document.querySelector("#statusMessage"),
  connectionBadge: document.querySelector("#connectionBadge")
};

let state = {
  folder: "화면캡처",
  action: "조회",
  histories: { gnb: [], subGnb: [], lnb: [], subLnb: [] },
  profiles: {}
};
let currentTabId = null;
let currentSiteKey = "default";
let saveTimer = 0;
let initialized = false;

function compactText(value) {
  return String(value ?? "").replace(/\s+/g, " ").trim();
}

function sanitizePart(value, fallback = "") {
  return compactText(value)
    .replace(/[<>:"/\\|?*\u0000-\u001F]/g, "-")
    .replace(/_+/g, "-")
    .replace(/[. ]+$/g, "")
    .slice(0, 80) || fallback;
}

function sanitizeFolder(value) {
  return String(value ?? "")
    .replace(/\\/g, "/")
    .split("/")
    .map((part) => sanitizePart(part))
    .filter((part) => part && part !== "." && part !== "..")
    .join("/") || "화면캡처";
}

function normalizedState(value) {
  const saved = value && typeof value === "object" ? value : {};
  const histories = {};
  for (const key of MENU_KEYS) {
    histories[key] = Array.isArray(saved.histories?.[key])
      ? saved.histories[key].filter(Boolean).slice(0, HISTORY_LIMIT)
      : [];
  }
  return {
    folder: saved.folder || "화면캡처",
    action: saved.action || "조회",
    histories,
    profiles: saved.profiles && typeof saved.profiles === "object" ? saved.profiles : {}
  };
}

function siteKeyForUrl(url) {
  try {
    return new URL(url).origin;
  } catch (error) {
    return "default";
  }
}

function ensureProfile() {
  if (!state.profiles[currentSiteKey]) {
    state.profiles[currentSiteKey] = { values: {} };
  }
  if (!state.profiles[currentSiteKey].values) {
    state.profiles[currentSiteKey].values = {};
  }
  return state.profiles[currentSiteKey];
}

function valuesFromForm() {
  return {
    folder: compactText(elements.folder.value),
    gnb: compactText(elements.gnb.value),
    subGnb: compactText(elements.subGnb.value),
    lnb: compactText(elements.lnb.value),
    subLnb: compactText(elements.subLnb.value),
    action: compactText(elements.action.value)
  };
}

function renderPreview() {
  const values = valuesFromForm();
  const filename = [values.gnb, values.subGnb, values.lnb, values.subLnb, values.action]
    .map((value) => sanitizePart(value, "…"))
    .join("_");
  elements.filenamePreview.textContent = `다운로드/${sanitizeFolder(values.folder)}/${filename}.png`;
}

function renderHistories() {
  for (const key of MENU_KEYS) {
    const list = document.querySelector(`#${key}History`);
    list.replaceChildren();
    for (const value of state.histories[key]) {
      const option = document.createElement("option");
      option.value = value;
      list.append(option);
    }
  }
}

async function saveState() {
  state.folder = compactText(elements.folder.value) || "화면캡처";
  state.action = compactText(elements.action.value) || "조회";
  const profile = ensureProfile();
  for (const key of MENU_KEYS) {
    profile.values[key] = compactText(elements[key].value);
  }
  await chrome.storage.local.set({ [STATE_KEY]: state });
}

function scheduleSave() {
  window.clearTimeout(saveTimer);
  saveTimer = window.setTimeout(() => saveState().catch(console.error), 80);
}

function rememberMenuValues(values) {
  for (const key of MENU_KEYS) {
    const value = compactText(values[key]);
    if (!value) {
      continue;
    }
    state.histories[key] = [value, ...state.histories[key].filter((item) => item !== value)]
      .slice(0, HISTORY_LIMIT);
  }
  renderHistories();
}

function setConnection(connected) {
  elements.connectionBadge.textContent = connected ? "연결됨" : "직접 입력";
  elements.connectionBadge.classList.toggle("connected", connected);
}

function setStatus(message, type = "") {
  elements.statusMessage.textContent = message;
  elements.statusMessage.className = `status-message ${type}`.trim();
}

function applyDetected(values, source = "auto") {
  let count = 0;
  const profile = ensureProfile();
  for (const key of MENU_KEYS) {
    const value = compactText(values?.[key]);
    if (!value || document.activeElement === elements[key]) {
      continue;
    }
    if (elements[key].value !== value) {
      elements[key].value = value;
      profile.values[key] = value;
      count += 1;
    }
  }
  if (count) {
    const description = source === "click" ? "메뉴 클릭" : "화면 변경";
    elements.detectMessage.textContent = `${description}을 반영했습니다. 감지되지 않은 값은 이전 값을 유지합니다.`;
    renderPreview();
    scheduleSave();
  }
}

async function requestMenuDetection() {
  if (!currentTabId) {
    setConnection(false);
    return;
  }

  try {
    const cached = await chrome.runtime.sendMessage({
      type: "GET_TAB_MENU_STATE",
      tabId: currentTabId
    });
    if (cached?.ok) {
      applyDetected(cached.data?.values, "cache");
    }

    const response = await chrome.tabs.sendMessage(currentTabId, { type: "DETECT_MENUS" });
    if (!response?.ok) {
      throw new Error("응답 없음");
    }
    applyDetected(response.values, "manual");
    setConnection(true);
    elements.detectMessage.textContent = "현재 화면을 확인했습니다. 감지되지 않은 값은 이전 값을 유지합니다.";
  } catch (error) {
    setConnection(false);
    elements.detectMessage.textContent = "이 페이지에서는 자동 감지를 사용할 수 없어 직접 입력합니다.";
  }
}

async function loadActiveTab() {
  const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
  const tab = tabs[0];
  currentTabId = tab?.id || null;
  currentSiteKey = siteKeyForUrl(tab?.url || "");
  const profile = ensureProfile();

  elements.folder.value = state.folder;
  elements.action.value = state.action;
  for (const key of MENU_KEYS) {
    elements[key].value = profile.values[key] || "";
  }
  renderPreview();
  await requestMenuDetection();
}

async function initialize() {
  const stored = await chrome.storage.local.get(STATE_KEY);
  state = normalizedState(stored[STATE_KEY]);
  renderHistories();
  await loadActiveTab();
  initialized = true;
}

for (const input of [elements.folder, elements.gnb, elements.subGnb, elements.lnb, elements.subLnb, elements.action]) {
  input.addEventListener("input", () => {
    renderPreview();
    scheduleSave();
  });
}

for (const key of MENU_KEYS) {
  elements[key].addEventListener("change", () => {
    rememberMenuValues({ [key]: elements[key].value });
    scheduleSave();
  });
}

elements.detectButton.addEventListener("click", () => {
  requestMenuDetection().catch(console.error);
});

elements.form.addEventListener("submit", async (event) => {
  event.preventDefault();
  if (!elements.form.reportValidity()) {
    setStatus("6개 입력칸을 모두 작성해 주세요.", "error");
    return;
  }

  const values = valuesFromForm();
  rememberMenuValues(values);
  await saveState();
  elements.captureButton.disabled = true;
  elements.captureButton.classList.add("busy");
  setStatus("현재 화면을 캡처하고 있습니다…", "working");

  try {
    const response = await chrome.runtime.sendMessage({
      type: "CAPTURE_VISIBLE",
      values
    });
    if (!response?.ok) {
      throw new Error(response?.error || "화면 캡처에 실패했습니다.");
    }
    setStatus(`저장 완료: 다운로드/${response.data.filename}`, "success");
  } catch (error) {
    setStatus(error?.message || "화면 캡처에 실패했습니다.", "error");
  } finally {
    elements.captureButton.disabled = false;
    elements.captureButton.classList.remove("busy");
  }
});

chrome.runtime.onMessage.addListener((message, sender) => {
  if (message?.type !== "MENU_STATE" || sender.tab?.id !== currentTabId) {
    return false;
  }
  applyDetected(message.values, message.source);
  setConnection(true);
  return false;
});

chrome.tabs.onActivated.addListener(() => {
  loadActiveTab().catch(console.error);
});

chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
  if (tabId === currentTabId && (changeInfo.status === "complete" || changeInfo.url)) {
    loadActiveTab().catch(console.error);
  }
});

window.addEventListener("pagehide", () => {
  if (!initialized) {
    return;
  }
  window.clearTimeout(saveTimer);
  saveState().catch(console.error);
});

initialize().catch((error) => {
  setConnection(false);
  setStatus(error?.message || "확장 프로그램을 초기화하지 못했습니다.", "error");
});