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>