ushiwaka
https://scrapbox.io/files/6870c5368566de327d7411af.jpg
About me
ushiwaka(うしわか)、2025年8月現在19歳
名前の由来は
繋がりは薄いが先祖に牛若丸がいた
ボカロPである 故 wowaka氏のアルファベットの手触りが好き
海外活躍を視野に入れて英語表記
多摩美術大学メディア芸術学部
2022年3月~ イラスト制作開始
X:https://x.com/ushiwaka_06
Contact:ushiwaka067@gmail.com
---
---
code:script.js
// --- URL変換 & マーカー機能 (修正版) ---
(async function() {
const customizerUrl = "https://scrapbox.io/api/code/yozba/yozbaUserScripts/scrapbox-url-customizer.js";
// 1. ページ読み込み時にあらかじめ機能をロードしておく(二度手間を防止)
// 読み込み時のレイアウト干渉を防ぐため Ne を一時退避
const originalNe = window.Ne;
await import(customizerUrl);
if (originalNe) window.Ne = originalNe;
// 2. URLボタンの登録(1回押せば即実行)
scrapbox.PopupMenu.addButton({
title: 'URL',
onClick: (text) => {
if (!/https?:\/\/\S+/.test(text)) return;
// ロード済みの変換エンジンを呼び出す
// 範囲選択された状態で実行されるようにイベントをシミュレート
const event = new MouseEvent('click', {bubbles: true});
document.querySelector('.popup-menu .buttontitle="URL"')?.dispatchEvent(event);
}
});
// 3. マーカーボタンの登録
scrapbox.PopupMenu.addButton({
title: 'マーカー',
onClick: text => text ? [[${text}]] : null
});
})();
// --- DailyReport テンプレート ---
// 日記テンプレート(時間割付き)
(async () => {
// --- dayjs を読み込み(非モジュール) ---
const importExternalJs = (url) =>
new Promise((res, rej) => {
if (document.querySelector(script[src="${url}"])) return res();
const s = document.createElement("script");
s.src = url;
s.addEventListener("load", res);
s.addEventListener("error", rej);
document.body.appendChild(s);
});
await importExternalJs("https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.8.36/dayjs.min.js");
// insertText(失敗時フォールバック付き)
let insertText;
try {
({ insertText } = await import("/api/code/customize/scrapbox-insert-text/script.js"));
} catch {
insertText = ({ text }) => {
const ta = document.getElementById("text-input");
if (!ta) return;
ta.focus();
const start = ta.selectionStart || 0;
ta.setRangeText(text);
ta.selectionStart = ta.selectionEnd = start + text.length;
const ev = document.createEvent("UIEvent");
ev.initEvent("input", true, false);
ta.dispatchEvent(ev);
};
}
// --------- フォーマッタ ----------
const pad2 = (n) => String(n).padStart(2, "0");
// アーカイブ用: YYYY/MM/DD, YYYY_MM
const fmtUDate = (d) => ${d.year()}/${pad2(d.month() + 1)}/${pad2(d.date())};
const fmtUMonth = (d) => ${d.year()}_${pad2(d.month() + 1)};
// --------- 時間割ブロック生成 ----------
function buildTimeTable() {
// 全角スペース2つ + 時刻
const ZS = "\u3000\u3000"; // 全角スペース×2
const hours = [];
for (let h = 5; h <= 25; h++) {
hours.push(> ${ZS}${h}:00);
}
return ">Task", ...hours.join("\n");
}
// --------- テンプレ生成(時間割付き) ----------
function buildBody(today /* dayjs */, yesterday, tomorrow) {
const line = <- [${fmtUDate(tomorrow)}] / [${fmtUMonth(today)}] / [${fmtUDate(yesterday)}] ->;
// 月曜なら週タグを1行
const isMonday = today.day() === 1;
const weekEnd = today.clone().add(6, "day");
const weeklyTag = [${today.format("YYYY/MM/DD")}-${pad2(weekEnd.month() + 1)}/${pad2(weekEnd.date())}];
// 時間割ブロック
const timetable = buildTimeTable();
// 挿入順:
// 1) YYYY/MM/DD (タイトル)
// 2) 空行×3
// 3) ---
// 4) 空行×1
// 5) 時間割(引用ブロック)
// 6) 空行
// 7) #日記
// 8) ナビ
// 9) (月曜のみ)週タグ
return [
${today.format("YYYY/MM/DD")},
,
,
,
---,
,
timetable,
,
#日記,
${line},
...(isMonday ? weeklyTag : []),
].join("\n");
}
// --------- メニュー ----------
scrapbox.PageMenu.addMenu({
title: "DailyReport",
image: "https://scrapbox.io/assets/img/logo.png",
onClick: async () => {
// タイトルのみの新規ページでのみ実行
if (!scrapbox.Page.lines || !(scrapbox.Page.lines.length == 1)) return;
const input = prompt("テンプレを展開する日付を相対(+N / -N)または絶対(YYYY-M-D)で入力(空欄=今日)");
if (input === null) return;
const raw = (input || "").trim();
const diff = raw ? parseInt(raw, 10) : 0;
const isRel = raw === "" || !Number.isNaN(diff);
const isAbs = raw.split("-").length === 3 && dayjs(raw).isValid();
if (!isRel && !isAbs) return;
const base = isAbs ? dayjs(raw).startOf("day") : dayjs().startOf("day").add(diff, "day");
const yesterday = base.clone().subtract(1, "day");
const tomorrow = base.clone().add(1, "day");
const ok = confirm(対象の日付は ${base.format("YYYY.M.D")} でよいですか?);
if (!ok) return;
insertText({ text: buildBody(base, yesterday, tomorrow) });
},
});
})();
// --- 選択範囲を[ ]で囲む「マーカー」ボタン ---
scrapbox.PopupMenu.addButton({
title: 'マーカー',
onClick: text => {
if (!text) return;
return [[${text}]];
}
});
// ---- split pins/non-pins with robust SPA handling & fail-safe ----
(function splitPinsStable(){
const GRID_SEL = '.page-list ul.grid';
let gridObs = null, rootObs = null, failTimer = null;
const raf = () => new Promise(r => requestAnimationFrame(r));
const isPin = li => li.classList.contains('pin') || li.classList.contains('pinned');
function measureCols(grid){
const cs = getComputedStyle(grid);
const tpl = cs.gridTemplateColumns;
if (tpl && tpl !== 'none') {
const n = tpl.split(' ').filter(Boolean).length;
if (n > 0) return n;
}
// CSSが未適用の瞬間などは推測(PC想定=7)
return matchMedia('(min-width:1024px)').matches ? 7 : 2;
}
const hasBigFirst = grid =>
!!grid.querySelector(':scope > li.page-list-item:first-child');
function calcRows(pinCount, cols, big){
if (pinCount <= 0) return 0;
const cap = big ? Math.max(0, cols - 2) : cols; // 1〜2行目の空き
let rem = pinCount, rows = 0;
rows++; rem -= cap;
if (rem > 0){ rows++; rem -= cap; }
if (rem > 0){ rows += Math.ceil(rem / cols); }
return rows;
}
function reset(items){
items.forEach(li=>{
li.style.removeProperty('grid-row');
li.style.removeProperty('grid-column');
delete li.dataset.rowLocked;
});
}
function hideWhileWorking(grid){
grid.classList.add('rowbreak-working');
clearTimeout(failTimer);
// 何があっても最長1秒で必ず表示に戻す
failTimer = setTimeout(()=> grid.classList.remove('rowbreak-working'), 1000);
}
function showAfter(grid){
clearTimeout(failTimer);
grid.classList.remove('rowbreak-working');
}
function apply(){
const grid = document.querySelector(GRID_SEL);
if (!grid) return;
hideWhileWorking(grid);
try {
// すべての要素を取得
const allItems = Array.from(grid.querySelectorAll(':scope > li.page-list-item'));
// --- 修正:'private'タグを含む要素を除外して「計算対象」を決める ---
const items = allItems.filter(li => {
const links = li.dataset.pageLinks || "";
if (links.includes("'private'")) {
li.style.display = 'none'; // 物理的に消す
return false; // 計算リストに入れない
}
li.style.display = ''; // 表示する
return true; // 計算リストに入れる
});
// ---------------------------------------------------------
const pins = items.filter(isPin);
const rest = items.filter(li => !isPin(li));
// スタイルリセットは全要素(allItems)に対して行う
reset(allItems);
const cols = measureCols(grid);
const rows = calcRows(pins.length, cols, hasBigFirst(grid));
// 除外された後の rest(非ピン記事)だけを順番に配置
rest.forEach((li, i)=>{
const row = rows + 1 + Math.floor(i / cols);
const col = (i % cols) + 1;
li.style.setProperty('grid-row', ${row} / span 1, 'important');
li.style.setProperty('grid-column', ${col} / span 1, 'important');
li.dataset.rowLocked = '1';
});
} finally {
showAfter(grid);
}
}
function attachGridObserver(){
const grid = document.querySelector(GRID_SEL);
if (!grid) return;
if (gridObs) gridObs.disconnect();
gridObs = new MutationObserver(apply);
gridObs.observe(grid, { childList: true });
}
async function boot(){
// 初期:グリッド出現まで待ち→適用
for (let i=0; i<180 && !document.querySelector(GRID_SEL); i++) {
await raf();
}
apply();
attachGridObserver();
// SPA遷移でグリッドが差し替わるのを常時監視
if (rootObs) rootObs.disconnect();
rootObs = new MutationObserver((muts)=>{
for (const m of muts){
const nodes = ...m.addedNodes, ...m.removedNodes;
if (nodes.some(n => n.nodeType===1 && (n.matches?.(GRID_SEL) || n.querySelector?.(GRID_SEL)))){
requestAnimationFrame(()=>{ apply(); attachGridObserver(); });
break;
}
}
});
rootObs.observe(document.body, { childList:true, subtree:true });
// 履歴遷移/復帰・リサイズ・タブ復帰でも再適用
addEventListener('popstate', ()=> requestAnimationFrame(()=>{ apply(); attachGridObserver(); }));
addEventListener('pageshow', ()=> requestAnimationFrame(()=>{ apply(); attachGridObserver(); }));
addEventListener('resize', ()=> apply(), { passive:true });
document.addEventListener('visibilitychange', ()=> {
if (document.visibilityState === 'visible') apply();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once:true });
} else {
boot();
}
})();
/* ══════════════════════════════
Night Mode 自動切替 (20:00–04:00)
══════════════════════════════ */
(function() {
function checkNightMode() {
const h = new Date().getHours();
const isNight = h >= 20 || h < 4;
document.documentElement.classList.toggle('night-mode', isNight);
}
checkNightMode();
setInterval(checkNightMode, 60000); // 毎分チェック
})();
/* ══════════════════════════════
Ctrl+Q / ミドルドラッグ → リンク化
──────────────────────────────
Ctrl+Q : 選択テキストを即リンク化
中ボタンドラッグ : テキスト選択(待機状態)
→ 右クリック : 待機テキストをリンク化
→ 再中ボタン : 前の待機をリンク化 → 新規選択
══════════════════════════════ */
(function() {
/* --- Ctrl+Q 即リンク化 --- */
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'q') {
e.preventDefault();
const sel = window.getSelection();
const text = sel.toString();
if (!text) return;
document.execCommand('insertText', false, [${text}]);
}
});
/* --- ミドルドラッグ選択 → リンク化 --- */
let middleHeld = false;
let dragStart = null;
let pendingRange = null;
let blockCtxMenu = false;
function linkifyPending() {
if (!pendingRange) return;
const text = pendingRange.toString();
if (!text) { pendingRange = null; return; }
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(pendingRange);
document.execCommand('insertText', false, [${text}]);
pendingRange = null;
}
function caretAt(x, y) {
if (document.caretRangeFromPoint) return document.caretRangeFromPoint(x, y);
if (document.caretPositionFromPoint) {
const pos = document.caretPositionFromPoint(x, y);
if (!pos) return null;
const r = document.createRange();
r.setStart(pos.offsetNode, pos.offset);
r.collapse(true);
return r;
}
return null;
}
// 中ボタン押下 → 前の待機をリンク化 → ドラッグ開始
document.addEventListener('mousedown', (e) => {
if (e.button === 1) {
e.preventDefault();
// ② 前の待機テキストがあればリンク化
linkifyPending();
middleHeld = true;
dragStart = caretAt(e.clientX, e.clientY);
if (dragStart) {
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(dragStart.cloneRange());
}
}
});
// ドラッグ中 → 選択範囲を拡張
document.addEventListener('mousemove', (e) => {
if (!middleHeld || !dragStart) return;
const end = caretAt(e.clientX, e.clientY);
if (!end) return;
try {
const range = document.createRange();
const cmp = dragStart.compareBoundaryPoints(Range.START_TO_START, end);
if (cmp <= 0) {
range.setStart(dragStart.startContainer, dragStart.startOffset);
range.setEnd(end.startContainer, end.startOffset);
} else {
range.setStart(end.startContainer, end.startOffset);
range.setEnd(dragStart.startContainer, dragStart.startOffset);
}
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
} catch (_) { /* 異なるノード間で範囲構築できない場合は無視 */ }
});
// 中ボタン離す → 選択を待機状態に保存
document.addEventListener('mouseup', (e) => {
if (e.button === 1 && middleHeld) {
middleHeld = false;
dragStart = null;
const sel = window.getSelection();
const text = sel.toString();
if (text && sel.rangeCount > 0) {
pendingRange = sel.getRangeAt(0).cloneRange();
}
}
});
// ① 右クリック → 待機テキストをリンク化
document.addEventListener('mousedown', (e) => {
if (e.button === 2 && pendingRange) {
e.preventDefault();
linkifyPending();
blockCtxMenu = true;
}
}, true);
// リンク化直後のコンテキストメニューを抑制
document.addEventListener('contextmenu', (e) => {
if (blockCtxMenu) {
e.preventDefault();
blockCtxMenu = false;
}
}, true);
})();
// ============================================================
// 人物ページ作成スクリプト v7 for Scrapbox
// 起動: Ctrl+Shift+P または PageMenu「PersonPage」ボタン
// script.js の末尾に追記して使用
// ============================================================
(function () {
'use strict';
const PROJECT = scrapbox.Project.name;
const CUSTOM_TAG_KEY = 'sbpc_custom_tags';
// ============================================================
// タグ管理
// ============================================================
const DEFAULT_TAGS = [
'3Dモデラー', 'アニメーター', 'イラストレーター', 'ボカロP',
'音楽家', 'モーショングラフィックデザイナー', '映像作家', 'サウンドデザイナー',
'ゲームディレクター', 'ゲームプロデューサー', 'ゲームクリエイター',
'漫画家', '映画監督', '脚本家', '俳優', '小説家', '哲学者', '写真家',
];
const loadCustomTags = () => {
try { return JSON.parse(localStorage.getItem(CUSTOM_TAG_KEY) ?? '[]'); }
catch { return []; }
};
const saveCustomTags = tags =>
localStorage.setItem(CUSTOM_TAG_KEY, JSON.stringify(tags));
// ============================================================
// 画像アップロード(Gyazo 経由)
// Scrapbox の /api/login/gyazo/oauth-upload/token でトークン取得
// → upload.gyazo.com/api/upload に POST
// ============================================================
let _gyazoToken = null;
async function getGyazoToken() {
if (_gyazoToken) return _gyazoToken;
const res = await fetch('/api/login/gyazo/oauth-upload/token');
if (!res.ok) throw new Error(token_fetch_failed:${res.status});
const data = await res.json();
if (!data.token) throw new Error('token_empty');
_gyazoToken = data.token;
return _gyazoToken;
}
async function uploadToGyazo(blob) {
const token = await getGyazoToken();
const fd = new FormData();
fd.append('imagedata', blob, 'paste.png');
fd.append('access_token', token);
const res = await fetch('https://upload.gyazo.com/api/upload', {
method: 'POST',
mode: 'cors',
credentials: 'omit',
body: fd,
});
if (!res.ok) throw new Error(gyazo_upload_failed:${res.status});
const data = await res.json();
// permalink_url 例: https://gyazo.com/abc123def456...
return data.permalink_url ?? data.url;
}
// ============================================================
// Scrapbox API:直近ページ(ピン留め除外)/ 検索
// ============================================================
async function fetchRecentPages(display = 15) {
try {
const res = await fetch(/api/pages/${PROJECT}?limit=40&sort=updated);
const data = await res.json();
return (data.pages ?? [])
.filter(p => !p.pin)
.slice(0, display)
.map(p => p.title);
} catch { return []; }
}
async function searchPages(query) {
if (!query.trim()) return [];
try {
// Scrapbox の全文検索エンドポイント(サイドバー検索と同じ)
const res = await fetch(
/api/pages/${PROJECT}/search/query?q=${encodeURIComponent(query)}
);
if (res.ok) {
const data = await res.json();
const pages = data.pages ?? [];
if (pages.length) return pages.map(p => p.title).filter(Boolean);
}
} catch {}
// フォールバック: タイトル一致検索
try {
const res = await fetch(/api/pages/${PROJECT}?q=${encodeURIComponent(query)}&limit=20);
const data = await res.json();
return (data.pages ?? []).map(p => p.title);
} catch { return []; }
}
// ============================================================
// ページ本文生成
// 行1 : gyazo画像URL または 空行
// 行2-3: 空行(画像スペース追加分)
// 行4 : 空行(計3行の空白ゾーン)
// 行5 : #タグ ...
// 行6 : 関連:AB...
// ============================================================
function buildBody(imageUrl, tags, relatedPages, freeText) {
const lines = [];
if (imageUrl) {
lines.push([${imageUrl}]); // Gyazo URL → Scrapboxが画像レンダリング
lines.push(''); // 画像下の空行
lines.push('');
} else {
lines.push(''); // 画像スペース 3行
lines.push('');
lines.push('');
}
if (tags.length) lines.push(tags.map(t => #${t}).join(' '));
if (relatedPages.length) {
lines.push(''); // タグ↔関連ページ間の空行
relatedPages.forEach(p => lines.push([${p}])); // 1件1行
}
if (freeText && freeText.trim()) {
lines.push('');
lines.push(freeText.trim());
}
return lines.join('\n');
}
// ============================================================
// CSS
// ============================================================
const STYLE = `
#sbpc-overlay {
position: fixed; inset: 0;
background: rgba(10,10,10,.80);
z-index: 99999;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(5px);
animation: sbpc-fi .14s ease;
}
@keyframes sbpc-fi { from{opacity:0} to{opacity:1} }
#sbpc-modal {
background: var(--color-bg,#fff);
border: 1px solid var(--color-border,#D2D5DA);
border-radius: 11px;
width: 560px; max-width: 96vw;
max-height: 92vh; overflow-y: auto;
box-shadow: 0 24px 64px rgba(0,0,0,.22);
animation: sbpc-si .17s ease;
font-family: var(--font-sans,'Inter','Noto Sans JP',sans-serif);
}
@keyframes sbpc-si {
from{transform:translateY(10px);opacity:0}
to{transform:translateY(0);opacity:1}
}
#sbpc-modal::-webkit-scrollbar { width:4px }
#sbpc-modal::-webkit-scrollbar-thumb {
background:var(--color-border,#D2D5DA); border-radius:4px;
}
.sbpc-head {
padding: 18px 22px 14px;
border-bottom: 1px solid var(--color-border,#D2D5DA);
display: flex; align-items: center; gap: 8px;
}
.sbpc-head h2 {
margin: 0; font-size: 14px; font-weight: 600;
color: var(--color-text,#1B1B1B); flex: 1;
}
.sbpc-shortcut {
font-size: 10px; color: #aaa;
background: #f4f4f4; border: 1px solid #e0e0e0;
border-radius: 4px; padding: 2px 6px; font-family: monospace;
}
.sbpc-section {
padding: 14px 22px;
border-bottom: 1px solid var(--color-border,#D2D5DA);
}
.sbpc-label {
font-size: 10px; font-weight: 700; color: #999;
text-transform: uppercase; letter-spacing: .08em;
margin-bottom: 8px;
display: flex; align-items: center; gap: 6px;
}
.sbpc-step {
background: var(--color-primary,#1F75BC); color: #fff;
border-radius: 50%; width: 16px; height: 16px;
font-size: 9px; display: flex; align-items: center;
justify-content: center; font-weight: 700; flex-shrink: 0;
}
.sbpc-input {
width: 100%; box-sizing: border-box;
background: #f8f8f8;
border: 1px solid var(--color-border,#D2D5DA);
border-radius: 7px;
color: var(--color-text,#1B1B1B);
padding: 9px 12px; font-size: 14px;
font-family: inherit; transition: border-color .15s;
}
.sbpc-input:focus { outline:none; border-color:var(--color-primary,#1F75BC); }
.sbpc-input::placeholder { color:#bbb; }
/* ② 画像ゾーン */
.sbpc-img-zone {
border: 2px dashed #D2D5DA;
border-radius: 8px;
padding: 18px 16px;
text-align: center;
cursor: pointer;
transition: border-color .15s, background .15s;
outline: none;
min-height: 80px;
display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 6px;
}
.sbpc-img-zone:hover,
.sbpc-img-zone:focus { border-color: var(--color-primary,#1F75BC); background: #f5f9ff; }
.sbpc-img-zone.dragover { border-color: var(--color-primary,#1F75BC); background: #edf4ff; }
.sbpc-img-zone.uploading { border-color: #bbb; background: #f8f8f8; }
.sbpc-img-zone.done { border-color: #4caf6e; background: #f0faf4; }
.sbpc-img-zone.error { border-color: #e06060; background: #fff5f5; }
.sbpc-img-hint { font-size: 12px; color: #bbb; }
.sbpc-img-hint .icon { font-size: 22px; margin-bottom: 2px; }
.sbpc-img-preview {
max-width: 100%; max-height: 120px;
border-radius: 4px; object-fit: contain;
}
.sbpc-img-url {
font-size: 10px; color: #4caf6e;
word-break: break-all; max-width: 100%;
background: #e8f5ed; border-radius: 4px; padding: 4px 8px;
}
.sbpc-img-err { font-size: 11px; color: #e06060; }
.sbpc-img-remove {
font-size: 11px; color: #aaa; cursor: pointer;
background: none; border: none; font-family: inherit;
text-decoration: underline; margin-top: 4px;
}
.sbpc-img-remove:hover { color: var(--color-accent,#CC2954); }
.sbpc-no-gyazo {
font-size: 11px; color: #bbb; margin-top: 4px;
}
/* ③ タグ */
.sbpc-tags-wrap {
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;
}
.sbpc-tag {
padding: 5px 11px; border-radius: 20px;
border: 1.5px solid var(--color-border,#D2D5DA);
background: #fff; font-size: 12px; color: #555;
cursor: pointer; user-select: none;
transition: all .12s; white-space: nowrap;
}
.sbpc-tag:hover { border-color:var(--color-primary,#1F75BC); color:var(--color-primary,#1F75BC); }
.sbpc-tag.on {
background: var(--color-primary,#1F75BC);
border-color: var(--color-primary,#1F75BC);
color: #fff; font-weight: 600;
}
.sbpc-tag.custom { border-color: #e8a0b0; color: var(--color-accent,#CC2954); }
.sbpc-tag.custom.on {
background: var(--color-accent,#CC2954);
border-color: var(--color-accent,#CC2954); color: #fff;
}
.sbpc-tag-add-row { display: flex; gap: 6px; margin-top: 4px; }
.sbpc-tag-add-row .sbpc-input { font-size: 12px; padding: 6px 10px; }
.sbpc-tag-add-btn {
padding: 6px 12px; border-radius: 7px;
background: #f0f0f0; border: 1px solid var(--color-border,#D2D5DA);
color: #555; font-size: 12px; font-weight: 600;
font-family: inherit; cursor: pointer; white-space: nowrap;
transition: background .12s;
}
.sbpc-tag-add-btn:hover { background: #e4e4e4; }
/* ④ 関連ページ */
.sbpc-chips {
display: flex; flex-wrap: wrap; gap: 6px;
margin-bottom: 10px; min-height: 28px;
}
.sbpc-chip {
display: flex; align-items: center; gap: 4px;
padding: 4px 10px; border-radius: 20px;
background: var(--color-primary,#1F75BC); color: #fff;
font-size: 12px; font-weight: 500;
animation: sbpc-chip-in .12s ease;
}
@keyframes sbpc-chip-in { from{transform:scale(.85);opacity:0} to{transform:scale(1);opacity:1} }
.sbpc-chip-rm {
cursor: pointer; opacity: .7; font-size: 11px;
line-height: 1; margin-left: 2px; transition: opacity .1s;
background: none; border: none; color: inherit; padding: 0;
}
.sbpc-chip-rm:hover { opacity: 1; }
.sbpc-chip-empty { font-size: 11px; color: #bbb; align-self: center; }
.sbpc-recent-label { font-size: 10px; color: #aaa; margin-bottom: 5px; }
.sbpc-candidates { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 10px; }
.sbpc-candidate {
padding: 4px 10px; border-radius: 20px;
border: 1px solid var(--color-border,#D2D5DA);
background: #fafafa; color: #555; font-size: 11px;
cursor: pointer; transition: all .1s;
max-width: 180px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.sbpc-candidate:hover {
border-color: var(--color-primary,#1F75BC);
color: var(--color-primary,#1F75BC); background: #f0f6ff;
}
.sbpc-candidate.used { opacity: .35; cursor: default; }
.sbpc-candidate.used:hover {
border-color: var(--color-border,#D2D5DA);
color: #555; background: #fafafa;
}
.sbpc-search-row { display: flex; gap: 6px; }
.sbpc-search-row .sbpc-input { font-size: 12px; padding: 7px 10px; }
#sbpc-search-results {
margin-top: 6px; display: flex; flex-wrap: wrap; gap: 5px; min-height: 0;
}
.sbpc-search-msg { font-size: 11px; color: #bbb; padding: 4px 0; }
/* フッター */
.sbpc-foot {
padding: 12px 22px;
display: flex; justify-content: flex-end; gap: 8px;
border-top: 1px solid var(--color-border,#D2D5DA);
position: sticky; bottom: 0;
background: var(--color-bg,#fff);
}
.sbpc-btn {
padding: 9px 20px; border-radius: 7px;
border: 1px solid transparent;
cursor: pointer; font-size: 13px; font-weight: 500;
font-family: inherit; transition: background .12s;
}
.sbpc-btn-cancel {
background: #f0f0f0; border-color: var(--color-border,#D2D5DA); color: #555;
}
.sbpc-btn-cancel:hover { background: #e4e4e4; }
.sbpc-btn-ok {
background: var(--color-primary,#1F75BC); color: #fff;
}
.sbpc-btn-ok:hover:not(:disabled) { background: #175fa0; }
.sbpc-btn-ok:disabled { opacity: .4; cursor: default; }
`;
function injectStyle() {
if (document.getElementById('sbpc-style7')) return;
const s = document.createElement('style');
s.id = 'sbpc-style7';
s.textContent = STYLE;
document.head.appendChild(s);
}
// ============================================================
// ダイアログ
// ============================================================
async function openDialog() {
injectStyle();
if (document.getElementById('sbpc-overlay')) return;
const recentPages = await fetchRecentPages(15);
const overlay = document.createElement('div');
overlay.id = 'sbpc-overlay';
overlay.innerHTML = `
<div id="sbpc-modal">
<div class="sbpc-head">
<h2>👤 人物ページ作成</h2>
<span class="sbpc-shortcut">Ctrl+Shift+P</span>
</div>
<!-- ① タイトル -->
<div class="sbpc-section">
<div class="sbpc-label"><span class="sbpc-step">1</span>人物名(ページタイトル)</div>
<input class="sbpc-input" id="sbpc-title" placeholder="例: 織田信長" />
</div>
<!-- ② 画像 -->
<div class="sbpc-section">
<div class="sbpc-label"><span class="sbpc-step">2</span>アイコン画像</div>
<div class="sbpc-img-zone" id="sbpc-img-zone" tabindex="0">
<div class="sbpc-img-hint">
<div class="icon">📷</div>
<div>画像をここにペースト(Ctrl+V)またはドラッグ&ドロップ</div>
<div style="font-size:10px;margin-top:3px">Gyazoに自動変換されます</div>
</div>
</div>
</div>
<!-- ③ 職業タグ -->
<div class="sbpc-section">
<div class="sbpc-label"><span class="sbpc-step">3</span>職業タグ</div>
<div class="sbpc-tags-wrap" id="sbpc-tags"></div>
<div class="sbpc-tag-add-row">
<input class="sbpc-input" id="sbpc-new-tag" placeholder="新しいタグを追加…" />
<button class="sbpc-tag-add-btn" id="sbpc-add-tag">+ 追加</button>
</div>
</div>
<!-- ④ 関連ページ -->
<div class="sbpc-section">
<div class="sbpc-label"><span class="sbpc-step">4</span>関連ページ</div>
<div class="sbpc-chips" id="sbpc-chips">
<span class="sbpc-chip-empty" id="sbpc-chips-empty">未選択</span>
</div>
<div class="sbpc-recent-label">直近のページ(ピン留め除外)</div>
<div class="sbpc-candidates" id="sbpc-recent"></div>
<div class="sbpc-search-row">
<input class="sbpc-input" id="sbpc-page-search" placeholder="ページを検索…" />
</div>
<div id="sbpc-search-results"></div>
</div>
<!-- ⑤ 関連情報 -->
<div class="sbpc-section">
<div class="sbpc-label"><span class="sbpc-step">5</span>関連情報(自由記入)</div>
<textarea class="sbpc-input" id="sbpc-free" rows="4"
placeholder="備考・経歴・メモなど自由に記入…"
style="resize:vertical;line-height:1.7;font-size:13px"></textarea>
</div>
<div class="sbpc-foot">
<button class="sbpc-btn sbpc-btn-cancel" id="sbpc-cancel">キャンセル</button>
<button class="sbpc-btn sbpc-btn-ok" id="sbpc-ok" disabled>ページを開く →</button>
</div>
</div>
`;
document.body.appendChild(overlay);
// ---- refs ----
const titleEl = document.getElementById('sbpc-title');
const imgZone = document.getElementById('sbpc-img-zone');
const tagsWrap = document.getElementById('sbpc-tags');
const newTagEl = document.getElementById('sbpc-new-tag');
const chipsEl = document.getElementById('sbpc-chips');
const recentEl = document.getElementById('sbpc-recent');
const searchEl = document.getElementById('sbpc-page-search');
const searchRes = document.getElementById('sbpc-search-results');
const freeEl = document.getElementById('sbpc-free');
const okBtn = document.getElementById('sbpc-ok');
// ---- state ----
let uploadedImageUrl = null;
let selectedTags = new Set();
let selectedPages = [];
// ============================================================
// ① タイトル
// ============================================================
const close = () => overlay.remove();
document.getElementById('sbpc-cancel').onclick = close;
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
overlay.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
titleEl.focus();
titleEl.addEventListener('input', () => {
okBtn.disabled = !titleEl.value.trim();
});
// ============================================================
// ② 画像アップロード
// ============================================================
function imgZoneReset() {
uploadedImageUrl = null;
imgZone.className = 'sbpc-img-zone';
imgZone.innerHTML = `
<div class="sbpc-img-hint">
<div class="icon">📷</div>
<div>画像をここにペースト(Ctrl+V)またはドラッグ&ドロップ</div>
<div style="font-size:10px;margin-top:3px">Gyazoに自動変換されます</div>
</div>`;
}
async function handleImageBlob(blob) {
imgZone.className = 'sbpc-img-zone uploading';
imgZone.innerHTML = '<div class="sbpc-img-hint"><div class="icon">⏳</div><div>Gyazoにアップロード中…</div></div>';
try {
const url = await uploadToGyazo(blob);
uploadedImageUrl = url;
// プレビュー表示
const localUrl = URL.createObjectURL(blob);
imgZone.className = 'sbpc-img-zone done';
imgZone.innerHTML = `
<img class="sbpc-img-preview" src="${localUrl}">
<div class="sbpc-img-url">${escHtml(url)}</div>
<button class="sbpc-img-remove" id="sbpc-img-remove">✕ 削除</button>`;
document.getElementById('sbpc-img-remove').onclick = imgZoneReset;
} catch (err) {
imgZone.className = 'sbpc-img-zone error';
const isNotLinked = err.message.includes('token');
imgZone.innerHTML = `
<div class="sbpc-img-hint"><div class="icon">⚠️</div></div>
<div class="sbpc-img-err">${isNotLinked
? 'GyazoがScrapboxに連携されていません'
: アップロード失敗: ${escHtml(err.message)}}</div>
${isNotLinked ? '<div class="sbpc-no-gyazo">ページ作成後に手動で画像を貼り付けてください</div>' : ''}
<button class="sbpc-img-remove" id="sbpc-img-remove">← 戻る</button>`;
document.getElementById('sbpc-img-remove').onclick = imgZoneReset;
}
}
// paste イベント(ゾーン内 + ダイアログ全体、テキスト入力中は除外)
function onPaste(e) {
const activeTag = document.activeElement?.tagName?.toLowerCase();
if (activeTag === 'input' || activeTag === 'textarea') return;
const items = Array.from(e.clipboardData?.items ?? []);
const imgItem = items.find(i => i.type.startsWith('image/'));
if (!imgItem) return;
e.preventDefault();
handleImageBlob(imgItem.getAsFile());
}
imgZone.addEventListener('paste', onPaste);
// モーダル全体でもキャッチ(ゾーンに明示的にフォーカスしなくても動く)
document.getElementById('sbpc-modal').addEventListener('paste', onPaste);
// ドラッグ&ドロップ
imgZone.addEventListener('dragover', e => { e.preventDefault(); imgZone.classList.add('dragover'); });
imgZone.addEventListener('dragleave', () => imgZone.classList.remove('dragover'));
imgZone.addEventListener('drop', e => {
e.preventDefault();
imgZone.classList.remove('dragover');
const file = e.dataTransfer?.files?.0;
if (file?.type.startsWith('image/')) handleImageBlob(file);
});
// クリックでゾーンにフォーカス(paste できるように)
imgZone.addEventListener('click', () => imgZone.focus());
// ============================================================
// ③ タグ
// ============================================================
function renderTag(tag, isCustom) {
const btn = document.createElement('button');
btn.className = 'sbpc-tag' + (isCustom ? ' custom' : '');
btn.textContent = tag;
btn.dataset.tag = tag;
btn.addEventListener('click', () => {
if (selectedTags.has(tag)) { selectedTags.delete(tag); btn.classList.remove('on'); }
else { selectedTags.add(tag); btn.classList.add('on'); }
});
tagsWrap.appendChild(btn);
}
DEFAULT_TAGS.forEach(t => renderTag(t, false));
loadCustomTags().forEach(t => renderTag(t, true));
const addTag = () => {
const t = newTagEl.value.trim();
if (!t) return;
const all = ...DEFAULT_TAGS, ...loadCustomTags();
if (!all.includes(t)) {
const saved = loadCustomTags();
saved.push(t); saveCustomTags(saved);
renderTag(t, true);
}
const el = tagsWrap.querySelector([data-tag="${CSS.escape(t)}"]);
if (el && !selectedTags.has(t)) el.click();
newTagEl.value = ''; newTagEl.focus();
};
document.getElementById('sbpc-add-tag').onclick = addTag;
newTagEl.addEventListener('keydown', e => { if (e.key === 'Enter') addTag(); });
// ============================================================
// ④ 関連ページ
// ============================================================
function addRelated(title) {
if (selectedPages.includes(title)) return;
selectedPages.push(title);
renderChips();
}
function removeRelated(title) {
selectedPages = selectedPages.filter(p => p !== title);
renderChips();
}
function renderChips() {
chipsEl.innerHTML = '';
if (!selectedPages.length) {
chipsEl.innerHTML = '<span class="sbpc-chip-empty">未選択</span>';
} else {
selectedPages.forEach(p => {
const chip = document.createElement('div');
chip.className = 'sbpc-chip';
chip.innerHTML =
<span style="max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(p)}</span>;
const rm = document.createElement('button');
rm.className = 'sbpc-chip-rm'; rm.textContent = '✕';
rm.onclick = () => removeRelated(p);
chip.appendChild(rm);
chipsEl.appendChild(chip);
});
}
syncCandidates();
}
function makeCandidateBtn(title) {
const btn = document.createElement('button');
btn.className = 'sbpc-candidate' + (selectedPages.includes(title) ? ' used' : '');
btn.textContent = title;
btn.dataset.page = title;
btn.onclick = () => { if (!selectedPages.includes(title)) addRelated(title); };
return btn;
}
function syncCandidates() {
document.querySelectorAll('.sbpc-candidate').forEach(btn => {
btn.classList.toggle('used', selectedPages.includes(btn.dataset.page));
});
}
recentPages.forEach(p => recentEl.appendChild(makeCandidateBtn(p)));
// 検索(280ms デバウンス)
let searchTimer = null;
searchEl.addEventListener('input', () => {
clearTimeout(searchTimer);
const q = searchEl.value.trim();
if (!q) { searchRes.innerHTML = ''; return; }
searchTimer = setTimeout(async () => {
searchRes.innerHTML = '<span class="sbpc-search-msg">検索中…</span>';
const results = await searchPages(q);
searchRes.innerHTML = '';
if (!results.length) {
searchRes.innerHTML = '<span class="sbpc-search-msg">見つかりませんでした</span>';
return;
}
results.forEach(p => searchRes.appendChild(makeCandidateBtn(p)));
}, 280);
});
// ============================================================
// ページ作成
// ============================================================
okBtn.onclick = () => {
const title = titleEl.value.trim();
if (!title) return;
const body = buildBody(
uploadedImageUrl,
...selectedTags,
selectedPages,
freeEl.value
);
const url = https://scrapbox.io/${PROJECT}/${encodeURIComponent(title)} +
?body=${encodeURIComponent(body)};
window.open(url, '_blank');
close();
};
titleEl.addEventListener('keydown', e => {
if (e.key === 'Enter' && !okBtn.disabled) okBtn.click();
});
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ============================================================
// 起動
// ============================================================
document.addEventListener('keydown', e => {
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'p') {
e.preventDefault();
openDialog();
}
});
scrapbox.PageMenu.addMenu({
title: 'PersonPage',
image: 'https://abs.twimg.com/emoji/v2/svg/1f464.svg',
onClick: openDialog,
});
})();
code:script.js
// ============================================================
// Scrapbox 統合ページ作成スクリプト
// 人物ページ(左)& 動画ページ(右)を 1 つのダイアログに集約
// PageMenu「+ページ作成」ボタンから起動
// script.js の末尾に追記して使用
// ============================================================
(function () {
'use strict';
const PROJECT = scrapbox.Project.name;
const PERSON_TAGS_KEY = 'sbpc_custom_tags';
const VIDEO_TAGS_KEY = 'sbvc_custom_tags';
const YT_LS_KEY = 'sbvc_from_yt';
const WIKI_LS_KEY = 'sbpc_from_wiki';
// ============================================================
// タグ管理
// ============================================================
const PERSON_DEFAULT_TAGS = [
'3Dモデラー', 'アニメーター', 'イラストレーター', 'ボカロP',
'音楽家', 'モーショングラフィックデザイナー', '映像作家', 'サウンドデザイナー',
'ゲームディレクター', 'ゲームプロデューサー', 'ゲームクリエイター',
'漫画家', '映画監督', '脚本家', '俳優', '小説家', '哲学者', '写真家',
];
const VIDEO_DEFAULT_TAGS = '音楽', '作曲解説', '楽曲', '映像', 'meme';
const loadTags = key => { try { return JSON.parse(localStorage.getItem(key) ?? '[]'); } catch { return []; } };
const saveTags = (key, tags) => localStorage.setItem(key, JSON.stringify(tags));
// ============================================================
// Gyazo アップロード
// ============================================================
let _gyazoToken = null;
async function getGyazoToken() {
if (_gyazoToken) return _gyazoToken;
const res = await fetch('/api/login/gyazo/oauth-upload/token');
if (!res.ok) throw new Error('not_linked');
const data = await res.json();
if (!data.token) throw new Error('token_empty');
_gyazoToken = data.token;
return _gyazoToken;
}
async function uploadToGyazo(blob) {
const token = await getGyazoToken();
const fd = new FormData();
fd.append('imagedata', blob, 'paste.png');
fd.append('access_token', token);
const res = await fetch('https://upload.gyazo.com/api/upload', {
method: 'POST', mode: 'cors', credentials: 'omit', body: fd,
});
if (!res.ok) throw new Error(upload_failed:${res.status});
const data = await res.json();
return data.permalink_url ?? data.url;
}
// ============================================================
// Scrapbox ページ API
// ============================================================
async function fetchRecentPages(display = 15) {
try {
const res = await fetch(/api/pages/${PROJECT}?limit=40&sort=updated);
const data = await res.json();
return (data.pages ?? []).filter(p => !p.pin).slice(0, display).map(p => p.title);
} catch { return []; }
}
async function searchPages(query) {
if (!query.trim()) return [];
try {
const res = await fetch(/api/pages/${PROJECT}/search/query?q=${encodeURIComponent(query)});
if (res.ok) {
const data = await res.json();
if (data.pages?.length) return data.pages.map(p => p.title).filter(Boolean);
}
} catch {}
try {
const res = await fetch(/api/pages/${PROJECT}?q=${encodeURIComponent(query)}&limit=20);
const data = await res.json();
return (data.pages ?? []).map(p => p.title);
} catch { return []; }
}
// ============================================================
// YouTube ユーティリティ
// ============================================================
function extractVideoId(url) {
const patterns = [
/?&v=(A-Za-z0-9_-{11})/,
/youtu\.be\/(A-Za-z0-9_-{11})/,
/shorts\/(A-Za-z0-9_-{11})/,
/embed\/(A-Za-z0-9_-{11})/,
];
for (const p of patterns) {
const m = url.match(p);
if (m) return m1;
}
return null;
}
// ============================================================
// 本文生成
// ============================================================
function buildPersonBody(imageUrl, tags, relatedPages, freeText) {
const lines = '', '';
if (imageUrl) { lines0 = [${imageUrl}]; }
if (tags.length) lines.push(tags.map(t => #${t}).join(' '));
if (relatedPages.length) {
lines.push('');
relatedPages.forEach(p => lines.push([${p}]));
}
if (freeText?.trim()) { lines.push(''); lines.push(freeText.trim()); }
return lines.join('\n');
}
function buildVideoBody(youtubeUrl, imageUrls, tags, relatedPages, freeText) {
const lines = [];
lines.push('');
lines.push([${youtubeUrl}]);
lines.push('');
if (imageUrls.length) { imageUrls.forEach(u => lines.push([${u}])); lines.push(''); }
if (tags.length) lines.push(tags.map(t => #${t}).join(' '));
if (relatedPages.length) {
lines.push('');
relatedPages.forEach(p => lines.push([${p}]));
}
if (freeText?.trim()) { lines.push(''); lines.push(freeText.trim()); }
return lines.join('\n');
}
const escHtml = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
// ============================================================
// CSS
// ============================================================
const STYLE = `
#sbcr-panel {
position: fixed;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
z-index: 99999;
display: flex;
width: min(1080px, 96vw);
max-height: 90vh;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 16px 64px rgba(0,0,0,.35), 0 0 0 1px rgba(0,0,0,.08);
animation: sbcr-in .18s ease;
font-family: var(--font-sans,'Inter','Noto Sans JP',sans-serif);
}
@keyframes sbcr-in {
from { opacity:0; transform:translate(-50%,-53%); }
to { opacity:1; transform:translate(-50%,-50%); }
}
/* 左右パネル共通 */
.sbcr-pane {
flex: 1; min-width: 0;
display: flex; flex-direction: column;
background: var(--color-bg,#fff);
overflow-y: auto;
max-height: 90vh;
}
.sbcr-pane::-webkit-scrollbar { width: 4px; }
.sbcr-pane::-webkit-scrollbar-thumb { background: #ddd; border-radius: 4px; }
/* 左:人物(白) */
#sbcr-person { border-right: 1px solid var(--color-border,#D2D5DA); }
/* 右:動画(わずかにトーンを変える) */
#sbcr-video { background: #fafafa; }
/* パネルヘッダー */
.sbcr-head {
padding: 14px 20px 12px;
border-bottom: 1px solid var(--color-border,#D2D5DA);
display: flex; align-items: center; gap: 8px;
position: sticky; top: 0; z-index: 1;
}
#sbcr-person .sbcr-head { background: #fff; }
#sbcr-video .sbcr-head { background: #fafafa; }
.sbcr-head h2 { margin:0; font-size:13px; font-weight:700; flex:1; }
.sbcr-head-icon { font-size:18px; }
/* セクション */
.sbcr-section { padding: 12px 20px; border-bottom: 1px solid #f0f0f0; }
.sbcr-label {
font-size: 9.5px; font-weight: 700; color: #aaa;
text-transform: uppercase; letter-spacing: .08em;
margin-bottom: 6px;
}
/* 入力 */
.sbcr-input {
width: 100%; box-sizing: border-box;
background: #f6f6f6; border: 1px solid #e4e4e4;
border-radius: 6px; color: var(--color-text,#1B1B1B);
padding: 8px 10px; font-size: 13px; font-family: inherit;
transition: border-color .15s;
}
#sbcr-video .sbcr-input { background: #f0f0f0; }
.sbcr-input:focus { outline:none; border-color: var(--color-primary,#1F75BC); }
.sbcr-input::placeholder { color:#ccc; }
/* サムネプレビュー */
.sbcr-preview { display:flex; gap:10px; align-items:flex-start; margin-top:8px; }
.sbcr-thumb {
flex-shrink:0; width:120px; height:68px;
border-radius:5px; overflow:hidden;
background:#e8e8e8; border:1px solid #e0e0e0;
display:flex; align-items:center; justify-content:center;
font-size:20px; color:#bbb;
}
.sbcr-thumb img { width:100%; height:100%; object-fit:cover; }
.sbcr-title-block { flex:1; display:flex; flex-direction:column; gap:5px; }
.sbcr-title-hint { font-size:10px; color:#aaa; }
.sbcr-title-hint.ok { color:#4caf6e; }
/* クリップボードボタン */
.sbcr-clip-btn {
padding:6px 10px; border-radius:6px; border:none;
background:var(--color-primary,#1F75BC); color:#fff;
font-size:11px; font-weight:600; font-family:inherit;
cursor:pointer; white-space:nowrap; transition:background .12s;
}
.sbcr-clip-btn:hover { background:#175fa0; }
/* 画像ゾーン */
.sbcr-img-zone {
border:1.5px dashed #d4d4d4; border-radius:7px;
padding:12px 14px; cursor:pointer; outline:none; min-height:52px;
transition:border-color .15s, background .15s;
}
.sbcr-img-zone:hover, .sbcr-img-zone:focus { border-color:var(--color-primary,#1F75BC); background:#f0f5ff; }
.sbcr-img-zone.dragover { border-color:var(--color-primary,#1F75BC); background:#e8f0ff; }
.sbcr-img-hint { display:flex; align-items:center; gap:7px; font-size:11px; color:#bbb; }
.sbcr-img-grid { display:flex; flex-wrap:wrap; gap:6px; }
.sbcr-img-thumb {
position:relative; width:64px; height:64px;
border-radius:5px; overflow:hidden; border:1px solid #e0e0e0;
}
.sbcr-img-thumb img { width:100%; height:100%; object-fit:cover; }
.sbcr-img-thumb .ov {
position:absolute; inset:0; background:rgba(255,255,255,.75);
display:flex; align-items:center; justify-content:center; font-size:16px;
}
.sbcr-img-thumb .rm {
position:absolute; top:2px; right:2px;
background:rgba(0,0,0,.5); color:#fff; border:none; border-radius:50%;
cursor:pointer; width:16px; height:16px; font-size:9px;
display:flex; align-items:center; justify-content:center;
}
.sbcr-img-thumb .rm:hover { background:rgba(180,0,0,.8); }
/* タグ */
.sbcr-tags { display:flex; flex-wrap:wrap; gap:5px; margin-bottom:8px; }
.sbcr-tag {
padding:4px 10px; border-radius:20px;
border:1.5px solid #e0e0e0;
background:#fff; font-size:11.5px; color:#666;
cursor:pointer; user-select:none; transition:all .12s; white-space:nowrap;
}
.sbcr-tag.p-tag:hover { border-color:var(--color-primary,#1F75BC); color:var(--color-primary,#1F75BC); }
.sbcr-tag.p-tag.on { background:var(--color-primary,#1F75BC); border-color:var(--color-primary,#1F75BC); color:#fff; font-weight:600; }
.sbcr-tag.v-tag:hover { border-color:#CC2954; color:#CC2954; }
.sbcr-tag.v-tag.on { background:#CC2954; border-color:#CC2954; color:#fff; font-weight:600; }
.sbcr-tag.custom { border-style:dashed; }
.sbcr-tag-add { display:flex; gap:5px; }
.sbcr-tag-add .sbcr-input { font-size:11.5px; padding:5px 9px; }
.sbcr-tag-add-btn {
padding:5px 10px; border-radius:6px;
background:#efefef; border:1px solid #e0e0e0;
color:#555; font-size:11.5px; font-weight:600;
font-family:inherit; cursor:pointer; white-space:nowrap; transition:background .12s;
}
.sbcr-tag-add-btn:hover { background:#e4e4e4; }
/* 関連ページ(共有エリア) */
#sbcr-shared {
background: var(--color-bg,#fff);
border-top: 2px solid var(--color-border,#D2D5DA);
padding: 12px 20px 16px;
}
#sbcr-shared .sbcr-label { color:#888; }
.sbcr-chips { display:flex; flex-wrap:wrap; gap:5px; margin-bottom:8px; min-height:26px; }
.sbcr-chip {
display:flex; align-items:center; gap:3px;
padding:3px 9px; border-radius:20px;
background:var(--color-primary,#1F75BC); color:#fff;
font-size:11.5px; font-weight:500;
}
.sbcr-chip-rm { cursor:pointer; opacity:.7; font-size:10px; background:none; border:none; color:inherit; padding:0; }
.sbcr-chip-rm:hover { opacity:1; }
.sbcr-chip-empty { font-size:11px; color:#bbb; align-self:center; }
.sbcr-recent-label { font-size:10px; color:#aaa; margin-bottom:4px; }
.sbcr-candidates { display:flex; flex-wrap:wrap; gap:4px; margin-bottom:8px; }
.sbcr-candidate {
padding:3px 9px; border-radius:20px;
border:1px solid #e4e4e4; background:#fafafa; color:#555; font-size:11px;
cursor:pointer; transition:all .1s;
max-width:160px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.sbcr-candidate:hover { border-color:var(--color-primary,#1F75BC); color:var(--color-primary,#1F75BC); background:#f0f6ff; }
.sbcr-candidate.used { opacity:.3; cursor:default; }
.sbcr-candidate.used:hover { border-color:#e4e4e4; color:#555; background:#fafafa; }
#sbcr-search-results { margin-top:5px; display:flex; flex-wrap:wrap; gap:4px; }
.sbcr-search-msg { font-size:11px; color:#bbb; }
/* フッター(ボタン行) */
#sbcr-footer {
background: var(--color-bg,#fff);
border-top: 1px solid var(--color-border,#D2D5DA);
padding: 12px 20px;
display: flex; gap: 8px; justify-content: flex-end;
position: sticky; bottom: 0;
}
.sbcr-btn {
padding:8px 18px; border-radius:7px; border:1px solid transparent;
cursor:pointer; font-size:13px; font-weight:500; font-family:inherit; transition:background .12s;
}
.sbcr-btn-cancel { background:#f0f0f0; border-color:#e0e0e0; color:#555; }
.sbcr-btn-cancel:hover { background:#e4e4e4; }
.sbcr-btn-person { background:var(--color-primary,#1F75BC); color:#fff; }
.sbcr-btn-person:hover:not(:disabled) { background:#175fa0; }
.sbcr-btn-video { background:#CC2954; color:#fff; }
.sbcr-btn-video:hover:not(:disabled) { background:#a01f42; }
.sbcr-btn-person:disabled, .sbcr-btn-video:disabled { opacity:.4; cursor:default; }
`;
function injectStyle() {
if (document.getElementById('sbcr-style')) return;
const s = document.createElement('style');
s.id = 'sbcr-style'; s.textContent = STYLE;
document.head.appendChild(s);
}
// ============================================================
// ダイアログ
// ============================================================
async function openDialog(prefill = {}) {
injectStyle();
if (document.getElementById('sbcr-panel')) return;
const recentPages = await fetchRecentPages(15);
const panel = document.createElement('div');
panel.id = 'sbcr-panel';
panel.innerHTML = `
<!-- 左:人物パネル -->
<div class="sbcr-pane" id="sbcr-person">
<div class="sbcr-head">
<span class="sbcr-head-icon">👤</span>
<h2>人物ページ</h2>
</div>
<div class="sbcr-section">
<div class="sbcr-label">① 人物名(ページタイトル)</div>
<input class="sbcr-input" id="p-name" placeholder="例: 織田信長" />
</div>
<div class="sbcr-section">
<div class="sbcr-label">② 画像</div>
<div class="sbcr-img-zone" id="p-img-zone" tabindex="0">
<div id="p-img-grid" class="sbcr-img-grid" style="display:none"></div>
<div class="sbcr-img-hint" id="p-img-hint">
<span style="font-size:18px">📷</span>
<span>Ctrl+V / ドロップ(Gyazo変換)</span>
</div>
</div>
<div style="margin-top:6px">
<div class="sbcr-label">または画像URL</div>
<input class="sbcr-input" id="p-img-url" placeholder="https://gyazo.com/ または Wikipedia画像URL" />
</div>
</div>
<div class="sbcr-section">
<div class="sbcr-label">③ 職業タグ</div>
<div class="sbcr-tags" id="p-tags"></div>
<div class="sbcr-tag-add">
<input class="sbcr-input" id="p-new-tag" placeholder="タグを追加…" />
<button class="sbcr-tag-add-btn" id="p-add-tag">+</button>
</div>
</div>
<div class="sbcr-section">
<div class="sbcr-label">関連情報(自由記入)</div>
<textarea class="sbcr-input" id="p-free" rows="3"
placeholder="備考・経歴・メモなど…"
style="resize:vertical;line-height:1.7;font-size:12px"></textarea>
</div>
</div>
<!-- 右:動画パネル -->
<div class="sbcr-pane" id="sbcr-video">
<div class="sbcr-head">
<span class="sbcr-head-icon">🎬</span>
<h2>動画ページ</h2>
</div>
<div class="sbcr-section">
<div class="sbcr-label">① YouTube URL</div>
<input class="sbcr-input" id="v-url" placeholder="https://www.youtube.com/watch?v=..." />
<div class="sbcr-preview" id="v-preview" style="display:none">
<div class="sbcr-thumb" id="v-thumb">▶</div>
<div class="sbcr-title-block">
<div class="sbcr-title-hint" id="v-title-hint">タイトルを入力してください</div>
<input class="sbcr-input" id="v-title" placeholder="動画タイトル" style="margin-top:2px" />
<button class="sbcr-clip-btn" id="v-clip-btn" style="margin-top:4px;align-self:flex-start">
📋 クリップボードから取得
</button>
</div>
</div>
</div>
<div class="sbcr-section">
<div class="sbcr-label">② 画像(複数可・Gyazo変換)</div>
<div class="sbcr-img-zone" id="v-img-zone" tabindex="0">
<div id="v-img-grid" class="sbcr-img-grid" style="display:none"></div>
<div class="sbcr-img-hint" id="v-img-hint">
<span style="font-size:18px">📷</span>
<span>Ctrl+V / ドロップ</span>
</div>
</div>
</div>
<div class="sbcr-section">
<div class="sbcr-label">③ タグ</div>
<div class="sbcr-tags" id="v-tags"></div>
<div class="sbcr-tag-add">
<input class="sbcr-input" id="v-new-tag" placeholder="タグを追加…" />
<button class="sbcr-tag-add-btn" id="v-add-tag">+</button>
</div>
</div>
<div class="sbcr-section">
<div class="sbcr-label">関連情報(自由記入)</div>
<textarea class="sbcr-input" id="v-free" rows="3"
placeholder="備考・解説メモなど…"
style="resize:vertical;line-height:1.7;font-size:12px"></textarea>
</div>
</div>
<!-- 下:共有エリア(関連ページ + ボタン) -->
`;
// 共有エリアを縦に並べるためにラッパーを作る
const wrapper = document.createElement('div');
wrapper.style.cssText = 'display:flex;flex-direction:column;flex:1;min-width:0;';
// パネルを横並びにするための内部コンテナ
const panesRow = document.createElement('div');
panesRow.style.cssText = 'display:flex;flex:1;overflow:hidden;';
// 共有エリア
const shared = document.createElement('div');
shared.id = 'sbcr-shared';
shared.innerHTML = `
<div class="sbcr-label" style="margin-bottom:8px">🔗 関連ページ(両方のページに共通)</div>
<div class="sbcr-chips" id="sbcr-chips"><span class="sbcr-chip-empty">未選択</span></div>
<div class="sbcr-recent-label">直近のページ(ピン留め除外)</div>
<div class="sbcr-candidates" id="sbcr-recent"></div>
<div style="display:flex;gap:6px">
<input class="sbcr-input" id="sbcr-search" placeholder="ページを検索 / Enter で新規リンク作成…"
style="font-size:12px;padding:7px 10px" />
</div>
<div id="sbcr-search-results"></div>
`;
const footer = document.createElement('div');
footer.id = 'sbcr-footer';
footer.innerHTML = `
<button class="sbcr-btn sbcr-btn-cancel" id="sbcr-cancel">キャンセル</button>
<button class="sbcr-btn sbcr-btn-person" id="sbcr-ok-person" disabled>👤 人物ページを開く</button>
<button class="sbcr-btn sbcr-btn-video" id="sbcr-ok-video" disabled>🎬 動画ページを開く</button>
`;
// DOM 組み立て
panesRow.innerHTML = panel.innerHTML;
wrapper.appendChild(panesRow);
wrapper.appendChild(shared);
wrapper.appendChild(footer);
panel.innerHTML = '';
panel.appendChild(wrapper);
document.body.appendChild(panel);
// ---- refs ----
const pName = document.getElementById('p-name');
const pImgZone = document.getElementById('p-img-zone');
const pImgGrid = document.getElementById('p-img-grid');
const pImgHint = document.getElementById('p-img-hint');
const pImgUrl = document.getElementById('p-img-url');
const pTagsWrap = document.getElementById('p-tags');
const pNewTag = document.getElementById('p-new-tag');
const pFree = document.getElementById('p-free');
const vUrl = document.getElementById('v-url');
const vPreview = document.getElementById('v-preview');
const vThumb = document.getElementById('v-thumb');
const vTitleHint = document.getElementById('v-title-hint');
const vTitle = document.getElementById('v-title');
const vClipBtn = document.getElementById('v-clip-btn');
const vImgZone = document.getElementById('v-img-zone');
const vImgGrid = document.getElementById('v-img-grid');
const vImgHint = document.getElementById('v-img-hint');
const vTagsWrap = document.getElementById('v-tags');
const vNewTag = document.getElementById('v-new-tag');
const vFree = document.getElementById('v-free');
const searchEl = document.getElementById('sbcr-search');
const searchRes = document.getElementById('sbcr-search-results');
const chipsEl = document.getElementById('sbcr-chips');
const recentEl = document.getElementById('sbcr-recent');
const okPerson = document.getElementById('sbcr-ok-person');
const okVideo = document.getElementById('sbcr-ok-video');
// ---- state ----
let pImages = [], vImages = [];
let pSelTags = new Set(), vSelTags = new Set();
let selPages = [];
// ============================================================
// 閉じる(キャンセル or Escape のみ)
// ============================================================
const close = () => panel.remove();
document.getElementById('sbcr-cancel').onclick = close;
panel.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
// ============================================================
// OK ボタン有効化
// ============================================================
pName.addEventListener('input', () => { okPerson.disabled = !pName.value.trim(); });
vUrl.addEventListener('input', () => { /* vTitle が入ったら有効化 */ });
vTitle.addEventListener('input',() => {
okVideo.disabled = !vUrl.value.trim() || !vTitle.value.trim();
});
vUrl.addEventListener('input', () => {
okVideo.disabled = !vUrl.value.trim() || !vTitle.value.trim();
});
// ============================================================
// prefill(ブックマークレット連携)
// ============================================================
if (prefill.person) {
const d = prefill.person;
if (d.title) pName.value = d.title;
if (d.imageUrl) pImgUrl.value = d.imageUrl;
if (d.intro) pFree.value = d.intro + (d.infobox ? '\n\n' + d.infobox : '');
if (d.title) okPerson.disabled = false;
}
if (prefill.video) {
const d = prefill.video;
if (d.url) {
vUrl.value = d.url;
vUrl.dispatchEvent(new Event('input', { bubbles: true }));
}
if (d.title) {
setTimeout(() => {
vTitle.value = d.title;
vTitleHint.textContent = '✅ ブックマークレットから取得';
vTitleHint.className = 'sbcr-title-hint ok';
okVideo.disabled = false;
}, 200);
}
}
// ============================================================
// YouTube URL → サムネ
// ============================================================
vUrl.addEventListener('input', () => {
const videoId = extractVideoId(vUrl.value.trim());
if (!videoId) { vPreview.style.display = 'none'; return; }
vPreview.style.display = 'flex';
vThumb.innerHTML = '⏳';
const img = new Image();
img.onload = () => { vThumb.innerHTML = ''; vThumb.appendChild(img); };
img.onerror = () => { vThumb.innerHTML = '▶'; };
img.src = https://img.youtube.com/vi/${videoId}/hqdefault.jpg;
img.style.cssText = 'width:100%;height:100%;object-fit:cover';
if (!vTitle.value) vTitle.focus();
});
// ============================================================
// クリップボードからタイトル取得
// ============================================================
vClipBtn.addEventListener('click', async () => {
try {
const text = (await navigator.clipboard.readText()).trim()
.replace(/\s*-–\s*YouTube\s*$/i, '').trim();
if (!text) { vTitleHint.textContent = '⚠ クリップボードが空です'; return; }
vTitle.value = text;
vTitleHint.textContent = '✅ 取得しました';
vTitleHint.className = 'sbcr-title-hint ok';
okVideo.disabled = !vUrl.value.trim();
} catch {
vTitleHint.textContent = '⚠ クリップボードへのアクセスを許可してください';
}
});
// ============================================================
// 画像ゾーン(汎用)
// ============================================================
function makeImageZone(zone, grid, hint, imagesArr) {
function renderGrid() {
grid.innerHTML = '';
if (!imagesArr.length) {
grid.style.display = 'none'; hint.style.display = '';
return;
}
grid.style.display = 'flex';
hint.innerHTML = <span style="font-size:10px;color:#bbb">+ペーストで追加(${imagesArr.length}枚)</span>;
imagesArr.forEach((img, idx) => {
const th = document.createElement('div'); th.className = 'sbcr-img-thumb';
th.innerHTML = <img src="${escHtml(img.localUrl)}">;
if (img.status === 'uploading') {
const ov = document.createElement('div'); ov.className = 'ov'; ov.textContent = '⏳';
th.appendChild(ov);
} else {
const rm = document.createElement('button'); rm.className = 'rm'; rm.textContent = '✕';
rm.onclick = e => {
e.stopPropagation();
URL.revokeObjectURL(img.localUrl);
imagesArr.splice(idx, 1); renderGrid();
};
th.appendChild(rm);
}
grid.appendChild(th);
});
}
async function handleBlob(blob) {
const localUrl = URL.createObjectURL(blob);
const entry = { blob, localUrl, gyazoUrl: null, status: 'uploading' };
imagesArr.push(entry); renderGrid();
try { entry.gyazoUrl = await uploadToGyazo(blob); entry.status = 'done'; }
catch { entry.status = 'error'; }
renderGrid();
}
zone.addEventListener('click', () => zone.focus());
zone.addEventListener('paste', e => {
const imgs = Array.from(e.clipboardData?.items ?? []).filter(i => i.type.startsWith('image/'));
if (!imgs.length) return; e.preventDefault();
imgs.forEach(i => handleBlob(i.getAsFile()));
});
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
e.preventDefault(); zone.classList.remove('dragover');
Array.from(e.dataTransfer?.files ?? []).filter(f => f.type.startsWith('image/')).forEach(f => handleBlob(f));
});
}
makeImageZone(pImgZone, pImgGrid, pImgHint, pImages);
makeImageZone(vImgZone, vImgGrid, vImgHint, vImages);
// モーダル全体の paste(テキスト入力外で画像をペーストしたらアクティブな方のゾーンに)
panel.addEventListener('paste', e => {
const tag = document.activeElement?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea') return;
const imgs = Array.from(e.clipboardData?.items ?? []).filter(i => i.type.startsWith('image/'));
if (!imgs.length) return; e.preventDefault();
// フォーカスがある側のゾーンに入れる(なければ人物側)
const inVideo = document.getElementById('sbcr-video').contains(document.activeElement);
imgs.forEach(i => (inVideo ? vImgZone : pImgZone).dispatchEvent(
Object.assign(new ClipboardEvent('paste'), { clipboardData: e.clipboardData })
));
});
// ============================================================
// タグ(汎用)
// ============================================================
function makeTagSystem(wrap, newInput, addBtn, defaultTags, storageKey, selSet, cssClass) {
function renderTag(tag, isCustom) {
const btn = document.createElement('button');
btn.className = sbcr-tag ${cssClass} + (isCustom ? ' custom' : '');
btn.textContent = tag; btn.dataset.tag = tag;
btn.addEventListener('click', () => {
if (selSet.has(tag)) { selSet.delete(tag); btn.classList.remove('on'); }
else { selSet.add(tag); btn.classList.add('on'); }
});
wrap.appendChild(btn);
}
defaultTags.forEach(t => renderTag(t, false));
loadTags(storageKey).forEach(t => renderTag(t, true));
const addTag = () => {
const t = newInput.value.trim(); if (!t) return;
const all = ...defaultTags, ...loadTags(storageKey);
if (!all.includes(t)) { const s = loadTags(storageKey); s.push(t); saveTags(storageKey, s); renderTag(t, true); }
const el = wrap.querySelector([data-tag="${CSS.escape(t)}"]);
if (el && !selSet.has(t)) el.click();
newInput.value = ''; newInput.focus();
};
addBtn.onclick = addTag;
newInput.addEventListener('keydown', e => { if (e.key === 'Enter') addTag(); });
}
makeTagSystem(pTagsWrap, pNewTag, document.getElementById('p-add-tag'),
PERSON_DEFAULT_TAGS, PERSON_TAGS_KEY, pSelTags, 'p-tag');
makeTagSystem(vTagsWrap, vNewTag, document.getElementById('v-add-tag'),
VIDEO_DEFAULT_TAGS, VIDEO_TAGS_KEY, vSelTags, 'v-tag');
// ============================================================
// 関連ページ(共有)
// ============================================================
function addRelated(t) { if (!selPages.includes(t)) { selPages.push(t); renderChips(); } }
function removeRelated(t) { selPages = selPages.filter(p => p !== t); renderChips(); }
function renderChips() {
chipsEl.innerHTML = '';
if (!selPages.length) { chipsEl.innerHTML = '<span class="sbcr-chip-empty">未選択</span>'; }
else selPages.forEach(p => {
const chip = document.createElement('div'); chip.className = 'sbcr-chip';
chip.innerHTML = <span style="max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(p)}</span>;
const rm = document.createElement('button'); rm.className = 'sbcr-chip-rm'; rm.textContent = '✕';
rm.onclick = () => removeRelated(p);
chip.appendChild(rm); chipsEl.appendChild(chip);
});
document.querySelectorAll('.sbcr-candidate').forEach(b =>
b.classList.toggle('used', selPages.includes(b.dataset.page))
);
}
function makeCandidateBtn(title) {
const btn = document.createElement('button');
btn.className = 'sbcr-candidate' + (selPages.includes(title) ? ' used' : '');
btn.textContent = title; btn.dataset.page = title;
btn.onclick = () => {
if (selPages.includes(title)) return;
addRelated(title);
if (searchEl.value.trim()) { searchEl.value = ''; searchRes.innerHTML = ''; }
};
return btn;
}
recentPages.forEach(p => recentEl.appendChild(makeCandidateBtn(p)));
// 検索 / Enter で新規リンク
searchEl.addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
const q = searchEl.value.trim(); if (!q) return;
e.preventDefault(); addRelated(q); searchEl.value = ''; searchRes.innerHTML = '';
});
let searchTimer = null;
searchEl.addEventListener('input', () => {
clearTimeout(searchTimer);
const q = searchEl.value.trim();
if (!q) { searchRes.innerHTML = ''; return; }
searchTimer = setTimeout(async () => {
searchRes.innerHTML = '<span class="sbcr-search-msg">検索中…</span>';
const results = await searchPages(q);
searchRes.innerHTML = '';
if (!results.length) { searchRes.innerHTML = '<span class="sbcr-search-msg">見つかりませんでした</span>'; return; }
results.forEach(p => searchRes.appendChild(makeCandidateBtn(p)));
}, 280);
});
// ============================================================
// ページ作成
// ============================================================
okPerson.onclick = async () => {
const name = pName.value.trim(); if (!name) return;
const waiting = pImages.filter(i => i.status === 'uploading');
if (waiting.length) {
okPerson.textContent = '⏳'; okPerson.disabled = true;
await Promise.allSettled(waiting.map(i => new Promise(res => {
const t = setInterval(() => { if (i.status !== 'uploading') { clearInterval(t); res(); } }, 150);
})));
okPerson.textContent = '👤 人物ページを開く'; okPerson.disabled = false;
}
const doneImgs = pImages.filter(i => i.status === 'done' && i.gyazoUrl).map(i => i.gyazoUrl);
const imgUrl = pImgUrl.value.trim() || doneImgs0 || null;
const body = buildPersonBody(imgUrl, ...pSelTags, selPages, pFree.value);
window.open(https://scrapbox.io/${PROJECT}/${encodeURIComponent(name)}?body=${encodeURIComponent(body)}, '_blank');
close();
};
okVideo.onclick = async () => {
const url = vUrl.value.trim();
const title = vTitle.value.trim();
if (!url || !title) return;
const waiting = vImages.filter(i => i.status === 'uploading');
if (waiting.length) {
okVideo.textContent = '⏳'; okVideo.disabled = true;
await Promise.allSettled(waiting.map(i => new Promise(res => {
const t = setInterval(() => { if (i.status !== 'uploading') { clearInterval(t); res(); } }, 150);
})));
okVideo.textContent = '🎬 動画ページを開く'; okVideo.disabled = false;
}
const doneImgs = vImages.filter(i => i.status === 'done' && i.gyazoUrl).map(i => i.gyazoUrl);
const body = buildVideoBody(url, doneImgs, ...vSelTags, selPages, vFree.value);
window.open(https://scrapbox.io/${PROJECT}/${encodeURIComponent(title)}?body=${encodeURIComponent(body)}, '_blank');
close();
};
pName.focus();
}
// ============================================================
// PageMenu ボタン
// ============================================================
scrapbox.PageMenu.addMenu({
title: 'ページ作成',
image: 'https://abs.twimg.com/emoji/v2/svg/1f4dd.svg',
onClick: () => setTimeout(openDialog, 0),
});
// ============================================================
// ブックマークレット連携(localStorage 検知)
// ============================================================
async function checkBookmarklets() {
const ytRaw = localStorage.getItem(YT_LS_KEY);
const wikiRaw = localStorage.getItem(WIKI_LS_KEY);
if (!ytRaw && !wikiRaw) return;
const prefill = {};
if (ytRaw) {
try {
const d = JSON.parse(ytRaw);
if (d.url && Date.now() - (d.ts ?? 0) < 30000) prefill.video = d;
} catch {}
localStorage.removeItem(YT_LS_KEY);
}
if (wikiRaw) {
try {
const d = JSON.parse(wikiRaw);
if (d.title && Date.now() - (d.ts ?? 0) < 30000) prefill.person = d;
} catch {}
localStorage.removeItem(WIKI_LS_KEY);
}
if (Object.keys(prefill).length) await openDialog(prefill);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(checkBookmarklets, 800));
} else {
setTimeout(checkBookmarklets, 800);
}
})();