ushiwaka
https://scrapbox.io/files/6870c5368566de327d7411af.jpg
About me
ushiwaka(うしわか)、2025年8月現在19歳
名前の由来は
繋がりは薄いが先祖に牛若丸がいた
ボカロPである 故 wowaka氏のアルファベットの手触りが好き 海外活躍を視野に入れて英語表記
多摩美術大学メディア芸術学部
2022年3月~ イラスト制作開始
Contact:ushiwaka067@gmail.com
---
---
code:script.js
// --- URL変換 & マーカー機能 (修正版) ---
(async function() {
// 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);
});
// 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);
}
}
// --------- テンプレ生成(時間割付き) ----------
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) 空行
// 8) ナビ
// 9) (月曜のみ)週タグ
return [
${today.format("YYYY/MM/DD")},
,
,
,
---,
,
timetable,
,
#日記,
${line},
].join("\n");
}
// --------- メニュー ----------
scrapbox.PageMenu.addMenu({
title: "DailyReport",
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){
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);
})();
// ============================================================
// 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_HASH_KEY = 'sbvc_yt'; // URLハッシュのキー
const WIKI_HASH_KEY = 'sbpc_wiki'; // URLハッシュのキー
// ============================================================
// タグ管理
// ============================================================
const PERSON_DEFAULT_TAGS = [
'3Dモデラー', 'アニメーター', 'イラストレーター', 'ボカロP',
'音楽家', 'モーショングラフィックデザイナー', '映像作家', 'サウンドデザイナー',
'ゲームディレクター', 'ゲームプロデューサー', 'ゲームクリエイター',
'漫画家', '映画監督', '脚本家', '俳優', '小説家', '哲学者', '写真家',
];
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);
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 = [
];
for (const p of patterns) {
const m = url.match(p);
}
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,'&').replace(/</g,'<').replace(/>/g,'>');
// ============================================================
// CSS
// ============================================================
const STYLE = `
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-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-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;
border-radius: 6px; color: var(--color-text,#1B1B1B);
padding: 8px 10px; font-size: 13px; font-family: inherit;
transition: border-color .15s;
}
.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;
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; }
/* 関連ページ(共有エリア) */
background: var(--color-bg,#fff);
border-top: 2px solid var(--color-border,#D2D5DA);
padding: 12px 20px 16px;
}
.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-msg { font-size:11px; color:#bbb; }
/* フッター(ボタン行) */
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>
<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;
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: 'ページ作成',
onClick: () => setTimeout(openDialog, 0),
});
// ============================================================
// ブックマークレット連携(URL ハッシュ方式)
// YouTube/Wikipedia 側でハッシュにデータを埋め込んで渡す
// ============================================================
async function checkBookmarklets() {
const hash = decodeURIComponent(location.hash);
if (!hash) return;
const prefill = {};
// YouTube データを抽出
const ytMatch = hash.match(/#sbvc_yt=(.+?)(?:&sbpc_wiki=|$)/s);
if (ytMatch) {
try {
const d = JSON.parse(ytMatch1); if (d.url) prefill.video = d;
} catch {}
}
// Wikipedia データを抽出
const wikiMatch = hash.match(/sbpc_wiki=(.+)/s);
if (wikiMatch) {
try {
const d = JSON.parse(wikiMatch1); if (d.title) prefill.person = d;
} catch {}
}
// ハッシュをクリア(データを消してURLを綺麗にする)
if (ytMatch || wikiMatch) {
history.replaceState(null, '', location.pathname + location.search);
}
if (Object.keys(prefill).length) await openDialog(prefill);
}
// ハッシュが存在する場合のみ検知を試みる(不要な処理を省く)
if (location.hash.includes('sbvc_yt') || location.hash.includes('sbpc_wiki')) {
// scrapbox オブジェクトの準備を待ってから実行
let tries = 0;
const waitAndCheck = () => {
tries++;
if (typeof scrapbox !== 'undefined' && scrapbox.Project?.name) {
setTimeout(checkBookmarklets, 300);
} else if (tries < 20) {
setTimeout(waitAndCheck, 200); // 最大 4 秒待つ
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', waitAndCheck);
} else {
waitAndCheck();
}
}
})();