スクリーンショットを貼れるページ

screenshot.html

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>スクリーンショットを貼れるページ</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
  <div class="tabs" id="tabs">
    <button class="tab" data-tab-id="1">1</button>
    <button class="tab" data-tab-id="2">2</button>
    <button class="tab" data-tab-id="3">3</button>
    <button class="tab" data-tab-id="4">4</button>
    <button class="tab" data-tab-id="5">5</button>
    <button class="tab" data-tab-id="6">6</button>
    <button class="tab" data-tab-id="7">7</button>
    <button class="tab" data-tab-id="8">8</button>
    <button class="tab" data-tab-id="9">9</button>
    <button class="tab" data-tab-id="10">10</button>
    <button class="tab" data-tab-id="11">11</button>
    <button class="tab" data-tab-id="12">12</button>
  </div>

  <div class="actions">
    <button class="btn ghost" id="uploadBtn">⬆️ アップロード</button>
    <button class="btn ghost" id="clearBtn">🗑️ クリア</button>
    <button class="btn primary" id="downloadImgBtn">⬇️ 現タブDL</button>
    <button class="btn primary" id="downloadAllBtn">⬇️ 全タブDL</button>
  </div>
</header>

<main>
  <div id="editor" class="editor" tabindex="0" aria-label="貼り付けエリア">
    <div class="hint">
      ここをクリックしてから、スクリーンショットを <b>Ctrl+V</b> で貼り付け<br>
      (Win+Shift+S → Alt+Tab → Ctrl+V)<br><br>
      ※ 画像は IndexedDB に保存されます(localStorage の容量制限なし)
    </div>
  </div>
</main>

<script src="https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"></script>
<script src="db.js"></script>
<script src="app.js"></script>
<script>
(async () => {
  try {
    await openDB();
    setupEventListeners();
    await initApp();
  } catch (err) {
    console.error(err);
    alert("IndexedDBの初期化に失敗しました。ブラウザ設定(保存/プライバシー)を確認してください。");
  }
})();
</script>
</body>
</html>


db.js

/* =========================
   IndexedDB関連の処理
========================= */

const DB_NAME = "screenshot_memo_idb_v1";
const DB_VER = 1;

let db = null;

function openDB() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, DB_VER);

    req.onupgradeneeded = (e) => {
      const d = req.result;

      if (!d.objectStoreNames.contains("tabs")) {
        d.createObjectStore("tabs", { keyPath: "id" });
      }

      if (!d.objectStoreNames.contains("images")) {
        const store = d.createObjectStore("images", { keyPath: "id", autoIncrement: true });
        store.createIndex("by_tab", "tabId", { unique: false });
        store.createIndex("by_created", "createdAt", { unique: false });
      }
    };

    req.onsuccess = () => {
      db = req.result;
      resolve(db);
    };
    req.onerror = () => reject(req.error);
  });
}

function tx(storeNames, mode = "readonly") {
  const t = db.transaction(storeNames, mode);
  return { t, stores: storeNames.reduce((acc, n) => (acc[n] = t.objectStore(n), acc), {}) };
}

function idbGet(store, key) {
  return new Promise((resolve, reject) => {
    const r = store.get(key);
    r.onsuccess = () => resolve(r.result ?? null);
    r.onerror = () => reject(r.error);
  });
}

function idbPut(store, val) {
  return new Promise((resolve, reject) => {
    const r = store.put(val);
    r.onsuccess = () => resolve(r.result);
    r.onerror = () => reject(r.error);
  });
}

function idbAdd(store, val) {
  return new Promise((resolve, reject) => {
    const r = store.add(val);
    r.onsuccess = () => resolve(r.result);
    r.onerror = () => reject(r.error);
  });
}

function idbDelete(store, key) {
  return new Promise((resolve, reject) => {
    const r = store.delete(key);
    r.onsuccess = () => resolve(true);
    r.onerror = () => reject(r.error);
  });
}

function idbGetAll(store) {
  return new Promise((resolve, reject) => {
    const r = store.getAll();
    r.onsuccess = () => resolve(r.result || []);
    r.onerror = () => reject(r.error);
  });
}

app.js

/* =========================
   アプリケーションのメイン機能
========================= */

/* =========================
   グローバル状態
========================= */
let activeTabId = 1;
let activeTab = null;
let currentObjectUrl = null;

/* =========================
   DOM要素
========================= */
const $tabs = document.getElementById("tabs");
const $editor = document.getElementById("editor");
const $status = document.getElementById("status");
const $downloadAllBtn = document.getElementById("downloadAllBtn");
const $uploadBtn = document.getElementById("uploadBtn");
const $clearBtn = document.getElementById("clearBtn");
const $downloadImgBtn = document.getElementById("downloadImgBtn");

/* =========================
   ユーティリティ関数
========================= */
function getTabsFromHTML() {
  const buttons = document.querySelectorAll("#tabs .tab");
  return Array.from(buttons).map(btn => ({
    id: Number(btn.dataset.tabId),
    name: btn.textContent.trim()
  }));
}

function setStatus(msg) {
  if (!$status) return;
  $status.textContent = msg || "";
}

function clearObjectUrl() {
  if (currentObjectUrl) {
    URL.revokeObjectURL(currentObjectUrl);
    currentObjectUrl = null;
  }
}

/* =========================
   タブ管理
========================= */
async function ensureDefaultTabs() {
  const htmlTabs = getTabsFromHTML();
  const { stores } = tx(["tabs"], "readwrite");

  for (let i = 0; i < htmlTabs.length; i++) {
    const t = htmlTabs[i];
    const existing = await idbGet(stores.tabs, t.id);
    if (!existing) {
      await idbPut(stores.tabs, {
        id: t.id,
        name: t.name,
        currentImageId: null,
        order: i,
        updatedAt: Date.now()
      });
    } else if (existing.order === undefined) {
      existing.order = i;
      await idbPut(stores.tabs, existing);
    }
  }
}

async function loadTabNames() {
  const { stores } = tx(["tabs"], "readonly");
  const tabs = await idbGetAll(stores.tabs);
  
  tabs.sort((a, b) => (a.order ?? a.id) - (b.order ?? b.id));
  
  const $tabsContainer = document.getElementById("tabs");
  const buttons = Array.from($tabsContainer.querySelectorAll(".tab"));
  
  buttons.forEach(btn => btn.remove());
  
  for (const tab of tabs) {
    const btn = buttons.find(b => Number(b.dataset.tabId) === tab.id);
    if (btn) {
      btn.textContent = tab.name;
      $tabsContainer.appendChild(btn);
    }
  }
}

async function loadTab(tabId) {
  const { stores } = tx(["tabs", "images"], "readonly");
  const tab = await idbGet(stores.tabs, tabId);
  if (!tab) return null;

  const idx = stores.images.index("by_tab");
  const req = idx.getAll(tabId);

  const images = await new Promise((resolve, reject) => {
    req.onsuccess = () => resolve(req.result || []);
    req.onerror = () => reject(req.error);
  });

  images.sort((a, b) => a.createdAt - b.createdAt);

  return { tab, images };
}

async function renameTab(tabId) {
  const { stores } = tx(["tabs"], "readonly");
  const tab = await idbGet(stores.tabs, tabId);
  if (!tab) return;

  const name = prompt("タブ名を変更", tab.name);
  if (!name) return;

  const { stores: writeStores } = tx(["tabs"], "readwrite");
  tab.name = name;
  tab.updatedAt = Date.now();
  await idbPut(writeStores.tabs, tab);

  const btn = document.querySelector(`#tabs .tab[data-tab-id="${tabId}"]`);
  if (btn) btn.textContent = name;

  if (activeTabId === tabId) {
    activeTab = tab;
    // ブラウザのタブ名も更新
    document.title = `${name} - スクリーンショットを貼れるページ`;
  }

  setStatus("タブ名を更新しました");
}

async function renderTabs() {
  const buttons = document.querySelectorAll("#tabs .tab");
  let dragSrcEl = null;

  buttons.forEach(btn => {
    const id = Number(btn.dataset.tabId);

    btn.classList.toggle("active", id === activeTabId);

    btn.onclick = async () => {
      if (id === activeTabId) return;
      activeTabId = id;
      await renderActive();
    };

    btn.ondblclick = async (e) => {
      e.preventDefault();
      e.stopPropagation();
      await renameTab(id);
    };

    btn.draggable = true;

    btn.addEventListener("dragstart", (e) => {
      dragSrcEl = btn;
      btn.classList.add("dragging");
      e.dataTransfer.effectAllowed = "move";
    });

    btn.addEventListener("dragend", () => {
      btn.classList.remove("dragging");
      buttons.forEach(b => b.classList.remove("drag-over"));
    });

    btn.addEventListener("dragover", (e) => {
      e.preventDefault();
      e.dataTransfer.dropEffect = "move";
      if (dragSrcEl !== btn) {
        btn.classList.add("drag-over");
      }
    });

    btn.addEventListener("dragleave", () => {
      btn.classList.remove("drag-over");
    });

    btn.addEventListener("drop", async (e) => {
      e.preventDefault();
      if (!dragSrcEl || dragSrcEl === btn) return;

      const $tabsContainer = document.getElementById("tabs");
      const allButtons = Array.from($tabsContainer.querySelectorAll(".tab"));
      const srcIndex = allButtons.indexOf(dragSrcEl);
      const targetIndex = allButtons.indexOf(btn);

      if (srcIndex < 0 || targetIndex < 0) return;

      if (srcIndex < targetIndex) {
        $tabsContainer.insertBefore(dragSrcEl, btn.nextSibling);
      } else {
        $tabsContainer.insertBefore(dragSrcEl, btn);
      }

      await saveTabOrder();
      btn.classList.remove("drag-over");
    });
  });
}

async function saveTabOrder() {
  const buttons = document.querySelectorAll("#tabs .tab");
  const { stores } = tx(["tabs"], "readwrite");

  for (let i = 0; i < buttons.length; i++) {
    const id = Number(buttons[i].dataset.tabId);
    const tab = await idbGet(stores.tabs, id);
    if (tab) {
      tab.order = i;
      await idbPut(stores.tabs, tab);
    }
  }
}

async function renderActive() {
  setStatus("読み込み中…");
  const res = await loadTab(activeTabId);
  if (!res) {
    setStatus("タブが見つかりません");
    return;
  }
  activeTab = res.tab;

  setEditorImages(res.images);

  await renderTabs();
  
  // ブラウザのタブ名を更新
  document.title = `${activeTab.name} - スクリーンショットを貼れるページ`;
  
  setStatus(`OK(タブ${activeTabId})`);
}

/* =========================
   画像管理
========================= */
function setEditorImages(images) {
  clearObjectUrl();
  $editor.innerHTML = "";

  if (!images || images.length === 0) {
    $editor.innerHTML = `<div class="hint">ここに Ctrl+V で貼り付け</div>`;
    $editor.classList.remove("hasImage");
    return;
  }

  let dragSrcEl = null;

  for (const rec of images) {
    const url = URL.createObjectURL(rec.blob);

    const wrap = document.createElement("div");
    wrap.className = "image-item";
    wrap.draggable = true;
    wrap.dataset.id = rec.id;

    const img = document.createElement("img");
    img.src = url;
    img.onload = () => URL.revokeObjectURL(url);

    img.ondblclick = (e) => {
      e.stopPropagation();
      openImageViewerForCrop(rec);
    };

    const delBtn = document.createElement("button");
    delBtn.className = "image-delete-btn";
    delBtn.textContent = "🗑";

    delBtn.onclick = async (e) => {
      e.stopPropagation();
      if (!confirm("この画像を削除しますか?")) return;

      const { stores } = tx(["images"], "readwrite");
      await idbDelete(stores.images, rec.id);

      const res = await loadTab(activeTab.id);
      setEditorImages(res.images);
      setStatus("画像を削除しました");
    };

    wrap.addEventListener("dragstart", (e) => {
      dragSrcEl = wrap;
      wrap.classList.add("dragging");
      e.dataTransfer.effectAllowed = "move";
    });

    wrap.addEventListener("dragend", () => {
      wrap.classList.remove("dragging");
      document.querySelectorAll(".image-item").forEach(el => {
        el.classList.remove("drag-over");
      });
    });

    wrap.addEventListener("dragover", (e) => {
      e.preventDefault();
      wrap.classList.add("drag-over");
    });

    wrap.addEventListener("dragleave", () => {
      wrap.classList.remove("drag-over");
    });

    wrap.addEventListener("drop", async (e) => {
      e.preventDefault();
      if (!dragSrcEl || dragSrcEl === wrap) return;

      const items = Array.from($editor.querySelectorAll(".image-item"));
      const srcIndex = items.indexOf(dragSrcEl);
      const targetIndex = items.indexOf(wrap);

      if (srcIndex < 0 || targetIndex < 0) return;

      if (srcIndex < targetIndex) {
        $editor.insertBefore(dragSrcEl, wrap.nextSibling);
      } else {
        $editor.insertBefore(dragSrcEl, wrap);
      }

      await saveNewOrder();
    });

    wrap.appendChild(img);
    wrap.appendChild(delBtn);
    
    // キャプションがあれば表示
    if (rec.caption) {
      const captionDiv = document.createElement("div");
      captionDiv.className = "image-caption";
      captionDiv.textContent = rec.caption;
      wrap.appendChild(captionDiv);
    }
    
    $editor.appendChild(wrap);
  }

  $editor.classList.add("hasImage");
}

async function handlePastedImageFile(file) {
  if (!activeTab) return;
  setStatus("画像保存中…");

  const { stores } = tx(["images"], "readwrite");

  await idbAdd(stores.images, {
    tabId: activeTab.id,
    blob: file,
    type: file.type || "image/png",
    createdAt: Date.now()
  });

  const res = await loadTab(activeTab.id);
  setEditorImages(res.images);

  setStatus("画像を追加しました");
}

async function saveNewOrder() {
  const items = Array.from($editor.querySelectorAll(".image-item"));

  const { stores } = tx(["images"], "readwrite");

  let base = Date.now();

  for (let i = 0; i < items.length; i++) {
    const id = Number(items[i].dataset.id);
    const rec = await idbGet(stores.images, id);
    if (!rec) continue;

    rec.createdAt = base + i;
    await idbPut(stores.images, rec);
  }

  setStatus("並び順を保存しました");
}

/* =========================
   トリミング・描画機能
========================= */
function openImageViewerForCrop(rec) {
  const url = URL.createObjectURL(rec.blob);

  const overlay = document.createElement("div");
  overlay.className = "image-viewer-overlay";

  const img = document.createElement("img");
  img.src = url;

  overlay.appendChild(img);
  document.body.appendChild(overlay);

  const toolbar = document.createElement("div");
  toolbar.className = "crop-toolbar";

  const copyBtn = document.createElement("button");
  copyBtn.textContent = "📋 コピー";
  copyBtn.className = "copy";

  const captionBtn = document.createElement("button");
  captionBtn.textContent = "📝 キャプション";
  captionBtn.className = "caption";

  const drawBtn = document.createElement("button");
  drawBtn.textContent = "🖍️ 四角描画";
  drawBtn.className = "draw";

  const applyBtn = document.createElement("button");
  applyBtn.textContent = "✂ トリミング";
  applyBtn.className = "apply";

  const cancelBtn = document.createElement("button");
  cancelBtn.textContent = "✖ 閉じる";
  cancelBtn.className = "cancel";

  toolbar.appendChild(copyBtn);
  toolbar.appendChild(captionBtn);
  toolbar.appendChild(drawBtn);
  toolbar.appendChild(applyBtn);
  toolbar.appendChild(cancelBtn);
  document.body.appendChild(toolbar);

  const rect = document.createElement("div");
  rect.className = "crop-rect";
  overlay.appendChild(rect);

  const drawCanvas = document.createElement("canvas");
  drawCanvas.className = "draw-canvas";
  overlay.appendChild(drawCanvas);

  let startX = 0, startY = 0;
  let endX = 0, endY = 0;
  let dragging = false;
  let mode = "crop";
  let drawnRects = [];

  function close() {
    URL.revokeObjectURL(url);
    overlay.remove();
    toolbar.remove();
    document.removeEventListener("keydown", onKey);
    img.removeEventListener("mousedown", onDown);
    drawCanvas.removeEventListener("mousedown", onDown);
    document.removeEventListener("mousemove", onMove);
    document.removeEventListener("mouseup", onUp);
  }

  function onKey(e) {
    if (e.key === "Escape") close();
  }

  cancelBtn.onclick = close;

  copyBtn.onclick = async () => {
    try {
      await navigator.clipboard.write([
        new ClipboardItem({
          [rec.blob.type]: rec.blob
        })
      ]);
      
      const originalText = copyBtn.textContent;
      copyBtn.textContent = "✔ コピーしました";
      copyBtn.style.background = "#4caf50";
      
      setTimeout(() => {
        copyBtn.textContent = originalText;
        copyBtn.style.background = "";
      }, 1500);
      
      setStatus("画像をクリップボードにコピーしました");
    } catch (err) {
      console.error("コピー失敗:", err);
      alert("画像のコピーに失敗しました。");
    }
  };

  captionBtn.onclick = async () => {
    const currentCaption = rec.caption || "";
    const newCaption = prompt("キャプションを入力", currentCaption);
    
    if (newCaption === null) return;
    
    const { stores } = tx(["images"], "readwrite");
    const rec2 = await idbGet(stores.images, rec.id);
    if (!rec2) return;
    
    rec2.caption = newCaption;
    await idbPut(stores.images, rec2);
    
    rec.caption = newCaption;
    
    setStatus("キャプションを保存しました");
    
    const res = await loadTab(activeTab.id);
    setEditorImages(res.images);
  };

  drawBtn.onclick = () => {
    if (mode === "draw") {
      mode = "crop";
      drawBtn.textContent = "🖍️ 四角描画";
      drawBtn.style.background = "";
      rect.style.display = "block";
    } else {
      mode = "draw";
      drawBtn.textContent = "✔ 描画モード";
      drawBtn.style.background = "#ff5722";
      rect.style.display = "none";
    }
  };

  function updateCanvasSize() {
    const imgRect = img.getBoundingClientRect();
    const overlayRect = overlay.getBoundingClientRect();
    
    const imgLeft = imgRect.left - overlayRect.left;
    const imgTop = imgRect.top - overlayRect.top;
    
    drawCanvas.style.left = imgLeft + "px";
    drawCanvas.style.top = imgTop + "px";
    drawCanvas.width = imgRect.width;
    drawCanvas.height = imgRect.height;
    drawCanvas.style.width = imgRect.width + "px";
    drawCanvas.style.height = imgRect.height + "px";
    
    redrawRects();
  }

  function redrawRects() {
    const ctx = drawCanvas.getContext("2d");
    ctx.clearRect(0, 0, drawCanvas.width, drawCanvas.height);
    
    const imgRect = img.getBoundingClientRect();
    const overlayRect = overlay.getBoundingClientRect();
    const imgLeft = imgRect.left - overlayRect.left;
    const imgTop = imgRect.top - overlayRect.top;
    
    ctx.strokeStyle = "#ff0000";
    ctx.lineWidth = 3;
    
    for (const r of drawnRects) {
      ctx.strokeRect(
        r.x - imgLeft,
        r.y - imgTop,
        r.width,
        r.height
      );
    }
  }

  overlay.addEventListener("click", (e) => {
    if (e.target === overlay) {
      close();
    }
  });

  img.addEventListener("click", (e) => {
    e.stopPropagation();
  });

  document.addEventListener("keydown", onKey);

  function onDown(e) {
    if (e.target !== img && e.target !== drawCanvas) return;

    e.preventDefault();
    dragging = true;
    const overlayRect = overlay.getBoundingClientRect();

    startX = e.clientX - overlayRect.left;
    startY = e.clientY - overlayRect.top;
    endX = startX;
    endY = startY;

    if (mode === "crop") {
      updateRect();
    }
  }

  function onMove(e) {
    if (!dragging) return;
    e.preventDefault();

    const overlayRect = overlay.getBoundingClientRect();
    endX = e.clientX - overlayRect.left;
    endY = e.clientY - overlayRect.top;

    if (mode === "crop") {
      updateRect();
    } else if (mode === "draw") {
      redrawRects();
      const ctx = drawCanvas.getContext("2d");
      const imgRect = img.getBoundingClientRect();
      const imgLeft = imgRect.left - overlayRect.left;
      const imgTop = imgRect.top - overlayRect.top;
      
      ctx.strokeStyle = "#ff0000";
      ctx.lineWidth = 3;
      ctx.strokeRect(
        Math.min(startX, endX) - imgLeft,
        Math.min(startY, endY) - imgTop,
        Math.abs(endX - startX),
        Math.abs(endY - startY)
      );
    }
  }

  function onUp(e) {
    if (dragging) {
      e.preventDefault();
      
      if (mode === "draw") {
        const w = Math.abs(endX - startX);
        const h = Math.abs(endY - startY);
        
        if (w > 5 && h > 5) {
          drawnRects.push({
            x: Math.min(startX, endX),
            y: Math.min(startY, endY),
            width: w,
            height: h
          });
          redrawRects();
        }
      }
    }
    dragging = false;
  }

  function updateRect() {
    const imgRect = img.getBoundingClientRect();
    const overlayRect = overlay.getBoundingClientRect();

    const imgLeft = imgRect.left - overlayRect.left;
    const imgTop = imgRect.top - overlayRect.top;
    const imgRight = imgLeft + imgRect.width;
    const imgBottom = imgTop + imgRect.height;

    const clampedStartX = Math.max(imgLeft, Math.min(imgRight, startX));
    const clampedStartY = Math.max(imgTop, Math.min(imgBottom, startY));
    const clampedEndX = Math.max(imgLeft, Math.min(imgRight, endX));
    const clampedEndY = Math.max(imgTop, Math.min(imgBottom, endY));

    const x = Math.min(clampedStartX, clampedEndX);
    const y = Math.min(clampedStartY, clampedEndY);
    const w = Math.abs(clampedEndX - clampedStartX);
    const h = Math.abs(clampedEndY - clampedStartY);

    rect.style.left = x + "px";
    rect.style.top = y + "px";
    rect.style.width = w + "px";
    rect.style.height = h + "px";
  }

  img.onload = () => {
    updateCanvasSize();
    img.addEventListener("mousedown", onDown);
    drawCanvas.addEventListener("mousedown", onDown);
    document.addEventListener("mousemove", onMove);
    document.addEventListener("mouseup", onUp);
  };

  applyBtn.onclick = async () => {
    if (mode === "crop") {
      const rectLeft = parseFloat(rect.style.left) || 0;
      const rectTop = parseFloat(rect.style.top) || 0;
      const rectWidth = parseFloat(rect.style.width) || 0;
      const rectHeight = parseFloat(rect.style.height) || 0;

      if (rectWidth < 10 || rectHeight < 10) {
        alert("範囲を選択してください");
        return;
      }

      const imgRect = img.getBoundingClientRect();
      const overlayRect = overlay.getBoundingClientRect();

      const imgLeft = imgRect.left - overlayRect.left;
      const imgTop = imgRect.top - overlayRect.top;

      const relativeX = rectLeft - imgLeft;
      const relativeY = rectTop - imgTop;

      const scaleX = img.naturalWidth / img.clientWidth;
      const scaleY = img.naturalHeight / img.clientHeight;

      const sx = relativeX * scaleX;
      const sy = relativeY * scaleY;
      const sw = rectWidth * scaleX;
      const sh = rectHeight * scaleY;

      const canvas = document.createElement("canvas");
      canvas.width = sw;
      canvas.height = sh;

      const ctx = canvas.getContext("2d");

      const sourceImg = new Image();
      sourceImg.onload = async () => {
        ctx.drawImage(sourceImg, sx, sy, sw, sh, 0, 0, sw, sh);

        canvas.toBlob(async (blob) => {
          if (!blob) return;

          const { stores } = tx(["images"], "readwrite");
          const rec2 = await idbGet(stores.images, rec.id);
          if (!rec2) return;

          rec2.blob = blob;
          rec2.type = blob.type;
          await idbPut(stores.images, rec2);

          const res = await loadTab(activeTab.id);
          setEditorImages(res.images);
          setStatus("トリミングしました");
          close();
        }, rec.type || "image/png");
      };
      sourceImg.src = url;
    } else if (mode === "draw") {
      if (drawnRects.length === 0) {
        alert("四角を描画してください");
        return;
      }

      const canvas = document.createElement("canvas");
      canvas.width = img.naturalWidth;
      canvas.height = img.naturalHeight;

      const ctx = canvas.getContext("2d");

      const sourceImg = new Image();
      sourceImg.onload = async () => {
        ctx.drawImage(sourceImg, 0, 0);

        const imgRect = img.getBoundingClientRect();
        const overlayRect = overlay.getBoundingClientRect();
        const imgLeft = imgRect.left - overlayRect.left;
        const imgTop = imgRect.top - overlayRect.top;
        const scaleX = img.naturalWidth / img.clientWidth;
        const scaleY = img.naturalHeight / img.clientHeight;

        ctx.strokeStyle = "#ff0000";
        ctx.lineWidth = 5 * Math.max(scaleX, scaleY);

        for (const r of drawnRects) {
          const x = (r.x - imgLeft) * scaleX;
          const y = (r.y - imgTop) * scaleY;
          const w = r.width * scaleX;
          const h = r.height * scaleY;
          ctx.strokeRect(x, y, w, h);
        }

        canvas.toBlob(async (blob) => {
          if (!blob) return;

          const { stores } = tx(["images"], "readwrite");
          const rec2 = await idbGet(stores.images, rec.id);
          if (!rec2) return;

          rec2.blob = blob;
          rec2.type = blob.type;
          await idbPut(stores.images, rec2);

          const res = await loadTab(activeTab.id);
          setEditorImages(res.images);
          setStatus("四角を描画しました");
          close();
        }, rec.type || "image/png");
      };
      sourceImg.src = url;
    }
  };
}

/* =========================
   アップロード機能
========================= */
async function handleImageUpload(files) {
  if (!activeTab || !files || files.length === 0) return;
  
  setStatus("画像アップロード中...");
  
  let uploadCount = 0;
  
  for (const file of files) {
    if (!file.type.startsWith("image/")) {
      continue;
    }
    
    const { stores } = tx(["images"], "readwrite");
    
    await idbAdd(stores.images, {
      tabId: activeTab.id,
      blob: file,
      type: file.type,
      createdAt: Date.now() + uploadCount
    });
    
    uploadCount++;
  }
  
  if (uploadCount > 0) {
    const res = await loadTab(activeTab.id);
    setEditorImages(res.images);
    setStatus(`${uploadCount}枚の画像をアップロードしました`);
  } else {
    setStatus("画像ファイルが見つかりませんでした");
  }
}

/* =========================
   ダウンロード機能
========================= */

// キャプション付き画像を生成する関数
function createImageWithCaption(blob, caption) {
  return new Promise((resolve) => {
    if (!caption) {
      resolve(blob);
      return;
    }

    const img = new Image();
    const url = URL.createObjectURL(blob);

    img.onload = () => {
      URL.revokeObjectURL(url);

      const canvas = document.createElement("canvas");
      const ctx = canvas.getContext("2d");

      // キャプション用の余白を計算
      const fontSize = Math.max(16, img.height * 0.03);
      ctx.font = `${fontSize}px sans-serif`;
      const lines = wrapText(ctx, caption, img.width - 40);
      const lineHeight = fontSize * 1.5;
      const captionHeight = Math.ceil(lines.length * lineHeight) + 30;

      canvas.width = img.width;
      canvas.height = img.height + captionHeight;

      // 画像を描画
      ctx.drawImage(img, 0, 0);

      // キャプション背景を描画
      ctx.fillStyle = "rgba(0,0,0,0.8)";
      ctx.fillRect(0, img.height, canvas.width, captionHeight);

      // キャプションテキストを描画
      ctx.fillStyle = "#fff";
      ctx.font = `${fontSize}px sans-serif`;
      ctx.textBaseline = "top";

      lines.forEach((line, i) => {
        ctx.fillText(line, 20, img.height + 15 + i * lineHeight);
      });

      canvas.toBlob((newBlob) => {
        resolve(newBlob);
      }, blob.type || "image/png");
    };

    img.src = url;
  });
}

// テキストを指定幅で折り返す関数
function wrapText(ctx, text, maxWidth) {
  const words = text.split("");
  const lines = [];
  let currentLine = "";

  for (const char of words) {
    const testLine = currentLine + char;
    const metrics = ctx.measureText(testLine);

    if (metrics.width > maxWidth && currentLine.length > 0) {
      lines.push(currentLine);
      currentLine = char;
    } else {
      currentLine = testLine;
    }
  }

  if (currentLine.length > 0) {
    lines.push(currentLine);
  }

  return lines;
}

async function downloadCurrentTab() {
  if (!activeTab) {
    alert("タブが選択されていません");
    return;
  }

  const { stores } = tx(["images"], "readonly");

  const idx = stores.images.index("by_tab");
  const req = idx.getAll(activeTab.id);
  const images = await new Promise((resolve, reject) => {
    req.onsuccess = () => resolve(req.result || []);
    req.onerror = () => reject(req.error);
  });

  if (!images.length) {
    alert("このタブには画像がありません");
    return;
  }

  images.sort((a, b) => a.createdAt - b.createdAt);

  for (let i = 0; i < images.length; i++) {
    const rec = images[i];
    
    // キャプション付き画像を生成
    const downloadBlob = await createImageWithCaption(rec.blob, rec.caption);
    
    const ext =
      (rec.type || rec.blob.type || "").includes("png") ? "png" :
        ((rec.type || rec.blob.type || "").includes("jpeg") ? "jpg" : "png");

    const filename = `${activeTab.name || "tab"}_${String(i + 1).padStart(3, "0")}.${ext}`;

    const url = URL.createObjectURL(downloadBlob);
    const a = document.createElement("a");
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();
    
    await new Promise(resolve => setTimeout(resolve, 100));
    URL.revokeObjectURL(url);
  }

  setStatus(`${images.length}枚の画像をダウンロードしました`);
}

async function downloadAllTabs() {
  if (!confirm("全タブの画像をすべてZIPでダウンロードしますか?")) return;

  setStatus("全タブZIP作成中…");

  const { stores } = tx(["tabs", "images"], "readonly");

  const tabs = await idbGetAll(stores.tabs);
  tabs.sort((a, b) => a.id - b.id);

  const zip = new JSZip();

  for (const tab of tabs) {
    const idx = stores.images.index("by_tab");
    const req = idx.getAll(tab.id);
    const images = await new Promise((resolve, reject) => {
      req.onsuccess = () => resolve(req.result || []);
      req.onerror = () => reject(req.error);
    });

    if (!images.length) continue;

    images.sort((a, b) => a.createdAt - b.createdAt);

    let i = 1;
    for (const rec of images) {
      // キャプション付き画像を生成
      const downloadBlob = await createImageWithCaption(rec.blob, rec.caption);
      
      const ext =
        (rec.type || rec.blob.type || "").includes("png") ? "png" :
          ((rec.type || rec.blob.type || "").includes("jpeg") ? "jpg" : "img");

      const safeTabName = (tab.name || `tab${tab.id}`).replace(/[\\\/:*?"<>|]/g, "_");
      const filename = `${safeTabName}_${String(i).padStart(3, "0")}.${ext}`;

      zip.file(filename, downloadBlob);
      i++;
    }
  }

  const blob = await zip.generateAsync({ type: "blob" });

  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = `all_tabs_${new Date().toISOString().slice(0, 10)}.zip`;
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 1000);

  setStatus("全タブZIPをダウンロードしました");
}

async function clearCurrentTab() {
  if (!activeTab) return;
  if (!confirm("このタブの画像とメモを消しますか?")) return;

  const { stores } = tx(["tabs", "images"], "readwrite");

  const idx = stores.images.index("by_tab");
  const req = idx.getAllKeys(activeTab.id);
  const keys = await new Promise(resolve => {
    req.onsuccess = () => resolve(req.result || []);
  });

  for (const k of keys) {
    await idbDelete(stores.images, k);
  }

  activeTab.updatedAt = Date.now();
  await idbPut(stores.tabs, activeTab);

  setEditorImages([]);
  setStatus("クリアしました");
}

/* =========================
   イベントリスナー設定
========================= */
function setupEventListeners() {
  $editor.addEventListener("paste", async (e) => {
    const items = e.clipboardData?.items;
    if (!items) return;

    for (const item of items) {
      if (item.type && item.type.startsWith("image/")) {
        e.preventDefault();
        const file = item.getAsFile();
        if (!file) return;
        await handlePastedImageFile(file);
        return;
      }
    }
  });

  $editor.addEventListener("click", () => $editor.focus());

  $uploadBtn.addEventListener("click", () => {
    const input = document.createElement("input");
    input.type = "file";
    input.accept = "image/*";
    input.multiple = true;
    
    input.onchange = async (e) => {
      const files = Array.from(e.target.files);
      await handleImageUpload(files);
    };
    
    input.click();
  });

  $clearBtn.addEventListener("click", clearCurrentTab);
  $downloadImgBtn.addEventListener("click", downloadCurrentTab);
  $downloadAllBtn.addEventListener("click", downloadAllTabs);
}

/* =========================
   初期化
========================= */
async function initApp() {
  await ensureDefaultTabs();
  await loadTabNames();
  await renderActive();
  setStatus("準備OK(Ctrl+Vで貼れます)");
}

style.css

  :root{
    --bg:#f5f5f5;
    --hdr:#222;
    --tab:#444;
    --tabOn:#4b6cb7;
    --card:#fff;
    --bd:#aaa;
    --muted:#888;
  }
  body{
    margin:0;
    font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Hiragino Sans", "Noto Sans JP", sans-serif;
    background:var(--bg);
    height:100vh;
    display:flex;
    flex-direction:column;
  }
  header{
    display:flex;
    justify-content:space-between;
    align-items:center;
    gap:8px;
    background:var(--hdr);
    color:#fff;
    padding:8px 10px;
  }
  .tabs{
    display:flex;
    gap:6px;
    flex-wrap:wrap;
    align-items:center;
  }
  .tab{
    background:var(--tab);
    color:#fff;
    border:none;
    padding:6px 10px;
    cursor:pointer;
    border-radius:6px;
    font-size:13px;
    transition: all 0.2s ease;
    user-select: none;
  }
  .tab.active{ background:var(--tabOn); }
  .tab.dragging{
    opacity: 0.4;
    cursor: grabbing;
  }
  .tab.drag-over{
    transform: scale(1.1);
    box-shadow: 0 0 0 2px #4b6cb7;
  }
  .tab:not(.dragging):hover{
    background: #555;
  }
  .tab.active:not(.dragging):hover{
    background: #5a7bc7;
  }
  .actions{
    display:flex;
    gap:6px;
    flex-wrap:wrap;
    align-items:center;
  }
  .btn{
    border:none;
    border-radius:8px;
    padding:7px 10px;
    cursor:pointer;
    font-size:13px;
  }
  .btn.primary{ background:#fff; color:#111; }
  .btn.ghost{ background:#333; color:#fff; }
  main{
    flex:1;
    display:flex;
    flex-direction:column;
    gap:10px;
    padding:10px;
    min-height:0;
  }
  .editor{
    flex:1;
    flex-direction: column;
    min-height:0;
    background:var(--card);
    border-radius:12px;
    border:2px dashed var(--bd);
    padding:10px;
    box-sizing:border-box;
    outline:none;
    overflow:auto;
    display:flex;
    align-items:flex-start;
    justify-content:flex-start;
    position:relative;
  }
  .hint{
    position:absolute;
    inset:0;
    display:flex;
    align-items:center;
    justify-content:center;
    text-align:center;
    color:var(--muted);
    padding:20px;
    pointer-events:none;
    font-size:14px;
    line-height:1.6;
  }
  .editor.hasImage .hint{ display:none; }
  .editor img{
    display:block;
    border-radius:10px;
    box-shadow:0 2px 10px rgba(0,0,0,.12);

    /* 原寸表示にする */
    max-width:100%;
    max-height:none;
  }

  .bar{
    display:flex;
    gap:10px;
    align-items:center;
    flex-wrap:wrap;
  }
  .note{
    width:100%;
    background:var(--card);
    border-radius:12px;
    border:1px solid #ddd;
    padding:10px;
    box-sizing:border-box;
    resize:vertical;
    min-height:72px;
    font-size:14px;
  }
  .status{
    color:#333;
    font-size:12px;
  }
  .pill{
    display:inline-flex;
    align-items:center;
    gap:6px;
    background:#fff;
    border:1px solid #ddd;
    border-radius:999px;
    padding:6px 10px;
    font-size:12px;
  }

  .image-item{
  position: relative;
  display: inline-block;
}

.image-item img{
  display: block;
}

/* 削除ボタン */
.image-delete-btn{
  position: absolute;
  top: 6px;
  right: 6px;
  background: rgba(0,0,0,0.7);
  color: #fff;
  border: none;
  border-radius: 50%;
  width: 28px;
  height: 28px;
  cursor: pointer;
  font-size: 16px;
  line-height: 28px;
  text-align: center;
}

.image-delete-btn:hover{
  background: rgba(200,0,0,0.9);
}

.image-item {
  position: relative;
  margin-bottom: 12px;
  cursor: grab;
}

.image-item.dragging {
  opacity: 0.4;
}

.image-item.drag-over {
  outline: 2px dashed #4b6cb7;
}

.image-delete-btn {
  position: absolute;
  top: 8px;
  right: 8px;
  border: none;
  background: rgba(0,0,0,0.6);
  color: #fff;
  border-radius: 6px;
  padding: 4px 8px;
  cursor: pointer;
}

/* ===== 画像拡大ビュー ===== */
.image-viewer-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.85);
  z-index: 9999;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
}

.image-viewer-overlay img {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  box-shadow: 0 10px 40px rgba(0,0,0,0.5);
  background: #000;
  border-radius: 8px;
}

/* ===== トリミングUI ===== */
.crop-toolbar {
  position: fixed;
  top: 20px;
  right: 20px;
  z-index: 10000;
  display: flex;
  gap: 10px;
}

.crop-toolbar button {
  padding: 10px 14px;
  border-radius: 8px;
  border: none;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.2s ease;
}

.crop-toolbar .copy {
  background: #666;
  color: #fff;
}

.crop-toolbar .copy:hover {
  background: #777;
}

.crop-toolbar .caption {
  background: #666;
  color: #fff;
}

.crop-toolbar .caption:hover {
  background: #777;
}

.crop-toolbar .draw {
  background: #666;
  color: #fff;
}

.crop-toolbar .draw:hover {
  background: #777;
}

.crop-toolbar .apply {
  background: #4b6cb7;
  color: #fff;
}

.crop-toolbar .apply:hover {
  background: #5a7bc7;
}

.crop-toolbar .cancel {
  background: #666;
  color: #fff;
}

.crop-toolbar .cancel:hover {
  background: #777;
}

.crop-rect {
  position: fixed;
  border: 2px dashed #4b6cb7;
  background: rgba(75,108,183,0.2);
  z-index: 10001;
  pointer-events: none;
}

.draw-canvas {
  position: fixed;
  z-index: 10002;
  pointer-events: auto;
  cursor: crosshair;
}

/* キャプション表示 */
.image-caption {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  background: rgba(0,0,0,0.8);
  color: #fff;
  padding: 8px 12px;
  font-size: 13px;
  line-height: 1.5;
  border-radius: 0 0 10px 10px;
  word-break: break-word;
  max-height: 80px;
  overflow-y: auto;
}


favicon.svg

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
  <rect width="32" height="32" rx="7" fill="#4b6cb7"/>
  
  <path d="M8 12V8H12" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
  <path d="M20 8H24V12" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
  <path d="M8 20V24H12" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
  <path d="M24 20V24H20" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
  
  <rect x="11" y="12" width="10" height="8" rx="1" fill="white"/>
  <circle cx="13.5" cy="14.5" r="1" fill="#4b6cb7"/>
  <path d="M11 18l3-3 2 2 2.5-2.5L21 17.5V19h-10v-1z" fill="#4b6cb7"/>
</svg>