ココフォリア用スニペットツール。ルーム別保存、AND検索、全方位リサイズ対応。
// ==UserScript==
// @name CCFOLIA Snippet Tool
// @namespace http://tampermonkey.net/
// @version 1.0
// @description ココフォリア用スニペットツール。ルーム別保存、AND検索、全方位リサイズ対応。
// @author User
// @match https://ccfolia.com/rooms/*
// @grant none
// @license MIT
// ==/UserScript==
(function() {
'use strict';
/**
* CCFOLIA Snippet Tool v1.0
* Features:
* - ルームごとのデータ保存 (ルームID依存)
* - AND検索 (スペース区切り)
* - 全方位(8方向)リサイズ
* - React Input Hackによる確実な入力
* - JSON Import/Export
*/
// --- 定数・初期設定 ---
const UI_ID = 'ccfolia-snippet-ui';
const TOGGLE_BTN_ID = 'ccfolia-snippet-toggle-btn';
const CONFIG_KEY_PREFIX = 'ccfolia_snippet_tool';
// ルームID取得 (URLから抽出)
const getRoomId = () => {
const match = window.location.pathname.match(/\/rooms\/([^\/?#]+)/);
return match ? match[1] : 'global';
};
const ROOM_ID = getRoomId();
// ストレージキー
const STORAGE_KEY_DATA = `${CONFIG_KEY_PREFIX}_data_${ROOM_ID}`;
const STORAGE_KEY_CONFIG = `${CONFIG_KEY_PREFIX}_config`;
// 初期データ (CoC向けサンプル)
const DEFAULT_DATA = {
sections: [
{
name: "探索・技能",
collapsed: false,
items: [
{ label: "目星", text: "CC<= 【目星】", character: "", tags: ["技能", "探索"] },
{ label: "聞き耳", text: "CC<= 【聞き耳】", character: "", tags: ["技能", "探索"] }
]
},
{
name: "戦闘",
collapsed: false,
items: [
{ label: "近接攻撃", text: "CC<= 【こぶし/パンチ】\n1d3+db ダメージ", character: "", tags: ["戦闘", "攻撃"] },
{ label: "回避", text: "CC<= 【回避】", character: "", tags: ["戦闘", "防御"] }
]
},
{
name: "SAN値",
collapsed: false,
items: [
{ label: "SANチェック", text: "CC<= 【SAN値チェック】\n1/1d3", character: "", tags: ["SAN", "正気度"] }
]
}
]
};
const DEFAULT_CONFIG = {
fontSize: 13,
opacity: 0.95
};
// --- ステート管理 ---
let snippetData = DEFAULT_DATA;
let configData = DEFAULT_CONFIG;
let editMode = { active: false, sectionIdx: null, itemIdx: null };
// --- スタイル定義 (CSS) ---
const styles = `
:root { --snip-font-size: 13px; --snip-opacity: 0.95; }
/* メインパネル */
#${UI_ID} {
position: fixed; top: 80px; right: 20px;
width: 320px; height: 450px;
min-width: 250px; min-height: 150px;
max-width: 95vw; max-height: 95vh;
background: rgba(30, 30, 30, var(--snip-opacity));
color: #eee;
border: 1px solid #555;
border-radius: 8px;
z-index: 9000;
display: flex; flex-direction: column;
font-family: "Helvetica Neue", Arial, sans-serif;
box-shadow: 0 4px 15px rgba(0,0,0,0.6);
transition: opacity 0.2s, background 0.2s;
font-size: var(--snip-font-size);
}
#${UI_ID}.hidden { display: none !important; }
#${UI_ID}.minimized {
height: 40px !important; width: 240px !important;
min-width: 0; min-height: 0; overflow: hidden; resize: none;
}
#${UI_ID}.minimized .resizer, #${UI_ID}.minimized .content { display: none; }
/* ヘッダー */
#${UI_ID} .header {
padding: 8px 12px; background: #444; border-bottom: 1px solid #555;
cursor: move; display: flex; justify-content: space-between;
align-items: center; user-select: none; flex-shrink: 0;
border-radius: 7px 7px 0 0; height: 40px; box-sizing: border-box;
}
#${UI_ID} .header .title { font-weight: bold; font-size: 14px; color: #fff; }
#${UI_ID} .header .controls button {
background: none; border: none; color: #ccc; cursor: pointer;
font-size: 16px; font-weight: bold; padding: 0 5px; margin-left: 2px;
}
#${UI_ID} .header .controls button:hover { color: #fff; }
/* コンテンツエリア */
#${UI_ID} .content {
flex: 1; display: flex; flex-direction: column; overflow: hidden; padding: 10px;
}
.snippet-search {
width: 100%; padding: 6px; margin-bottom: 8px; background: #222;
border: 1px solid #444; color: #fff; border-radius: 4px; box-sizing: border-box;
font-size: var(--snip-font-size);
}
.snippet-search:focus { border-color: #88c0d0; outline: none; }
.snippet-list { flex: 1; overflow-y: auto; margin-bottom: 8px; padding-right: 4px; scrollbar-width: thin; }
/* リサイズハンドル (8方向) */
.resizer { position: absolute; background: transparent; z-index: 9001; }
.resizer-n { top: 0; left: 0; right: 0; height: 6px; cursor: n-resize; }
.resizer-e { top: 0; right: 0; bottom: 0; width: 6px; cursor: e-resize; }
.resizer-s { bottom: 0; left: 0; right: 0; height: 6px; cursor: s-resize; }
.resizer-w { top: 0; left: 0; bottom: 0; width: 6px; cursor: w-resize; }
.resizer-ne { top: 0; right: 0; width: 12px; height: 12px; cursor: ne-resize; z-index: 9002; }
.resizer-nw { top: 0; left: 0; width: 12px; height: 12px; cursor: nw-resize; z-index: 9002; }
.resizer-se { bottom: 0; right: 0; width: 12px; height: 12px; cursor: se-resize; z-index: 9002; }
.resizer-sw { bottom: 0; left: 0; width: 12px; height: 12px; cursor: sw-resize; z-index: 9002; }
/* セクション表示 */
.snippet-section { margin-bottom: 8px; }
.snippet-section-header {
display: flex; justify-content: space-between; align-items: center;
border-bottom: 1px solid #444; margin-bottom: 4px; padding: 4px 6px;
cursor: pointer; user-select: none;
background: rgba(255, 255, 255, 0.05); border-radius: 4px;
}
.snippet-section-header:hover { background: rgba(255, 255, 255, 0.1); }
.snippet-section-title { font-size: 12px; color: #88c0d0; font-weight: bold; display: flex; align-items: center;}
.snippet-section-title .icon { font-size: 10px; width: 16px; color: #aaa; }
.section-controls button {
background: none; border: 1px solid transparent; color: #666;
cursor: pointer; font-size: 10px; padding: 0 4px; border-radius: 3px;
}
.section-controls button:hover { color: #fff; background: #555; }
/* アイテム表示 */
.snippet-items-container { display: block; }
.snippet-items-container.collapsed { display: none; }
.snippet-item {
position: relative; padding: 8px; margin-bottom: 4px; background: #333;
border-radius: 4px; cursor: pointer; border: 1px solid transparent; margin-left: 4px;
transition: background 0.1s;
}
.snippet-item:hover { background: #444; border-color: #666; }
.snippet-item .header-line { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
.snippet-item .char-badge {
font-size: 10px; background: #5e81ac; color: #eceff4;
padding: 1px 4px; border-radius: 3px; font-weight: bold;
}
.snippet-item .label { font-weight: bold; color: #e5e9f0; }
.snippet-item .preview {
font-size: 0.9em; color: #aaa; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; display: block;
}
.snippet-tags { margin-top: 4px; }
.snippet-tag {
display: inline-block; background: #2c3e50; color: #aeb6bf;
font-size: 10px; padding: 1px 5px; border-radius: 3px; margin-right: 4px;
}
.item-actions { position: absolute; top: 6px; right: 6px; display: none; }
.snippet-item:hover .item-actions { display: block; }
.item-actions button {
background: #222; border: 1px solid #555; color: #fff;
border-radius: 3px; font-size: 10px; cursor: pointer; padding: 3px 8px;
}
.item-actions button:hover { background: #555; }
/* フッターボタン */
.snippet-toolbar { display: flex; gap: 6px; border-top: 1px solid #444; padding-top: 10px; flex-shrink: 0; }
.snippet-btn {
flex: 1; padding: 6px; background: #3b3b3b; border: 1px solid #555;
color: #ddd; border-radius: 4px; cursor: pointer; font-size: 12px;
text-align: center;
}
.snippet-btn:hover { background: #505050; color: #fff; }
.snippet-btn.primary { background: #2e8b57; border-color: #2e8b57; color: #fff; }
.snippet-btn.primary:hover { background: #3cb371; }
.snippet-btn.danger { background: #a11; border-color: #a11; color: #fff; }
.snippet-btn.danger:hover { background: #c33; }
/* ヘッダーへのトグルボタン */
#${TOGGLE_BTN_ID} {
height: 32px; padding: 0 12px; margin-right: 8px;
background: rgba(0,0,0,0.4); border: 1px solid rgba(255,255,255,0.2);
color: #fff; border-radius: 4px; cursor: pointer;
font-size: 13px; font-weight: bold; display: flex; align-items: center;
}
#${TOGGLE_BTN_ID}:hover { background: rgba(0,0,0,0.6); border-color: rgba(255,255,255,0.4); }
/* モーダル */
.snip-modal {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0,0,0,0.7); z-index: 9999; display: none;
justify-content: center; align-items: center;
}
.modal-content {
background: #252525; padding: 20px; width: 420px; max-width: 90%;
border-radius: 8px; color: #fff; box-shadow: 0 0 25px rgba(0,0,0,0.8);
display: flex; flex-direction: column; gap: 12px; border: 1px solid #444;
}
.modal-content h3 { margin: 0 0 8px 0; border-bottom: 1px solid #444; padding-bottom: 8px; }
.form-group { display: flex; flex-direction: column; }
.form-group label { font-size: 12px; color: #aaa; margin-bottom: 4px; font-weight: bold; }
.form-group input, .form-group textarea, .form-group select {
background: #111; border: 1px solid #444; color: #fff;
padding: 8px; border-radius: 4px; font-size: 13px;
}
.form-group input:focus, .form-group textarea:focus { border-color: #88c0d0; outline: none; }
.form-group textarea { min-height: 80px; resize: vertical; font-family: monospace; }
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; }
.config-section { border-top: 1px solid #444; margin-top: 10px; padding-top: 10px; }
`;
// --- データ保存・読込 ---
function loadData() {
try {
const savedData = localStorage.getItem(STORAGE_KEY_DATA);
snippetData = savedData ? JSON.parse(savedData) : JSON.parse(JSON.stringify(DEFAULT_DATA));
} catch (e) {
console.error("Data Load Error:", e);
snippetData = JSON.parse(JSON.stringify(DEFAULT_DATA));
}
try {
const savedConfig = localStorage.getItem(STORAGE_KEY_CONFIG);
configData = savedConfig ? { ...DEFAULT_CONFIG, ...JSON.parse(savedConfig) } : { ...DEFAULT_CONFIG };
} catch (e) {
console.error("Config Load Error:", e);
}
}
function saveData() {
localStorage.setItem(STORAGE_KEY_DATA, JSON.stringify(snippetData));
}
function saveConfig() {
localStorage.setItem(STORAGE_KEY_CONFIG, JSON.stringify(configData));
applyConfig();
}
function applyConfig() {
const panel = document.getElementById(UI_ID);
if (panel) {
panel.style.setProperty('--snip-font-size', `${configData.fontSize}px`);
panel.style.setProperty('--snip-opacity', configData.opacity);
}
}
// --- React Input Hack (重要) ---
// React管理下のinput/textareaに値をセットしてイベントを発火させる
function setNativeValue(element, value) {
const valueSetter = Object.getOwnPropertyDescriptor(element, 'value').set;
const prototype = Object.getPrototypeOf(element);
const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value').set;
if (valueSetter && valueSetter !== prototypeValueSetter) {
prototypeValueSetter.call(element, value);
} else {
valueSetter.call(element, value);
}
element.dispatchEvent(new Event('input', { bubbles: true }));
}
// --- アクション: 貼り付け ---
async function applySnippet(item) {
// キャラクター名セット
if (item.character && item.character.trim() !== "") {
const charInput = document.querySelector('input[name="name"]');
if (charInput) {
setNativeValue(charInput, item.character);
// Reactのステート更新待ち
await new Promise(r => setTimeout(r, 50));
}
}
// テキストセット
const chatInput = document.querySelector('textarea[data-testid="chat-input"]') || document.querySelector('textarea[name="text"]');
if (chatInput) {
setNativeValue(chatInput, item.text);
chatInput.focus();
} else {
alert("チャット入力欄が見つかりません。");
}
}
// セクション移動
function moveSection(index, direction) {
if (direction === 'up' && index > 0) {
[snippetData.sections[index], snippetData.sections[index - 1]] = [snippetData.sections[index - 1], snippetData.sections[index]];
} else if (direction === 'down' && index < snippetData.sections.length - 1) {
[snippetData.sections[index], snippetData.sections[index + 1]] = [snippetData.sections[index + 1], snippetData.sections[index]];
}
saveData(); renderList();
}
// グローバル公開 (HTML内のonclick用)
window.moveSnippetSection = moveSection;
// --- UI生成 ---
function createUI() {
// スタイル注入
const styleEl = document.createElement('style');
styleEl.textContent = styles;
document.head.appendChild(styleEl);
// メインパネル
const panel = document.createElement('div');
panel.id = UI_ID;
panel.classList.add('hidden');
panel.innerHTML = `
<div class="resizer resizer-n"></div><div class="resizer resizer-e"></div><div class="resizer resizer-s"></div><div class="resizer resizer-w"></div>
<div class="resizer resizer-ne"></div><div class="resizer resizer-nw"></div><div class="resizer resizer-se"></div><div class="resizer resizer-sw"></div>
<div class="header">
<span class="title">Snippet Tool v1.0</span>
<div class="controls">
<button id="snip-config-btn" title="設定">⚙</button>
<button id="snip-min-btn" title="最小化/復元">_</button>
<button id="snip-close-btn" title="閉じる">×</button>
</div>
</div>
<div class="content">
<input type="text" class="snippet-search" placeholder="検索 (スペースでAND検索)..." id="snip-search">
<div class="snippet-list" id="snip-list"></div>
<div class="snippet-toolbar">
<button class="snippet-btn primary" id="snip-add-btn">+ 追加</button>
<button class="snippet-btn" id="snip-import-btn">読込</button>
<button class="snippet-btn" id="snip-export-btn">保存</button>
</div>
</div>
`;
document.body.appendChild(panel);
// アイテム編集モーダル
const editModal = document.createElement('div');
editModal.className = 'snip-modal';
editModal.id = 'snip-edit-modal';
editModal.innerHTML = `
<div class="modal-content">
<h3 id="snip-modal-title">アイテム編集</h3>
<div class="form-group">
<label>セクション (新規入力可)</label>
<input type="text" list="snip-section-list" id="snip-in-section" placeholder="セクション名">
<datalist id="snip-section-list"></datalist>
</div>
<div class="form-group">
<label>キャラクター名 (任意・完全一致で切替)</label>
<input type="text" id="snip-in-char" placeholder="例: PC1">
</div>
<div class="form-group">
<label>ラベル</label>
<input type="text" id="snip-in-label" placeholder="リストに表示される名前">
</div>
<div class="form-group">
<label>テキスト (チャット本文)</label>
<textarea id="snip-in-text"></textarea>
</div>
<div class="form-group">
<label>タグ (カンマ区切り)</label>
<input type="text" id="snip-in-tags" placeholder="例: 戦闘, 攻撃">
</div>
<div class="modal-footer">
<button class="snippet-btn danger" id="snip-edit-delete" style="margin-right:auto;">削除</button>
<button class="snippet-btn" id="snip-edit-cancel">キャンセル</button>
<button class="snippet-btn primary" id="snip-edit-save">保存</button>
</div>
</div>
`;
document.body.appendChild(editModal);
// 設定モーダル
const configModal = document.createElement('div');
configModal.className = 'snip-modal';
configModal.id = 'snip-config-modal';
configModal.innerHTML = `
<div class="modal-content">
<h3>設定</h3>
<div class="form-group">
<label>フォントサイズ (px)</label>
<input type="number" id="cfg-font-size" min="10" max="24">
</div>
<div class="form-group">
<label>背景の不透明度 (0.1 ~ 1.0)</label>
<input type="number" id="cfg-opacity" min="0.1" max="1.0" step="0.05">
</div>
<div class="config-section">
<label style="color:#ff6b6b;">データ初期化</label>
<p style="font-size:11px; color:#aaa; margin:4px 0 8px;">このルームのスニペットをすべて削除し、初期状態に戻します。</p>
<button class="snippet-btn danger" id="cfg-reset-btn">初期化を実行</button>
</div>
<div class="modal-footer">
<button class="snippet-btn" id="cfg-close-btn">閉じる</button>
<button class="snippet-btn primary" id="cfg-save-btn">設定を保存</button>
</div>
</div>
`;
document.body.appendChild(configModal);
// ファイル入力用 (Hidden)
const fileInput = document.createElement('input');
fileInput.type = 'file'; fileInput.accept = '.json'; fileInput.style.display = 'none';
document.body.appendChild(fileInput);
applyConfig();
// --- イベントリスナー設定 ---
// 8方向リサイズ処理
const resizers = panel.querySelectorAll('.resizer');
const minW = 250, minH = 150;
resizers.forEach(resizer => resizer.addEventListener('mousedown', initResize));
function initResize(e) {
e.preventDefault(); // テキスト選択防止
const resizer = e.target;
const direction = resizer.className.replace('resizer resizer-', '');
const startX = e.clientX, startY = e.clientY;
const startW = parseInt(document.defaultView.getComputedStyle(panel).width, 10);
const startH = parseInt(document.defaultView.getComputedStyle(panel).height, 10);
const startLeft = panel.getBoundingClientRect().left;
const startTop = panel.getBoundingClientRect().top;
function doResize(e) {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
// 横方向
if (direction.includes('e')) {
panel.style.width = Math.max(minW, startW + dx) + 'px';
} else if (direction.includes('w')) {
const newW = Math.max(minW, startW - dx);
panel.style.width = newW + 'px';
if (newW > minW) panel.style.left = (startLeft + dx) + 'px';
}
// 縦方向
if (direction.includes('s')) {
panel.style.height = Math.max(minH, startH + dy) + 'px';
} else if (direction.includes('n')) {
const newH = Math.max(minH, startH - dy);
panel.style.height = newH + 'px';
if (newH > minH) panel.style.top = (startTop + dy) + 'px';
}
}
function stopResize() {
window.removeEventListener('mousemove', doResize);
window.removeEventListener('mouseup', stopResize);
}
window.addEventListener('mousemove', doResize);
window.addEventListener('mouseup', stopResize);
}
// ドラッグ移動
const header = panel.querySelector('.header');
let isDragging = false, offX, offY;
header.addEventListener('mousedown', e => {
if(e.target.tagName === 'BUTTON') return;
isDragging = true;
const rect = panel.getBoundingClientRect();
offX = e.clientX - rect.left;
offY = e.clientY - rect.top;
});
document.addEventListener('mousemove', e => {
if(!isDragging) return;
panel.style.top = (e.clientY - offY) + 'px';
panel.style.left = (e.clientX - offX) + 'px';
panel.style.right = 'auto'; // Right固定解除
});
document.addEventListener('mouseup', () => isDragging = false);
// パネル制御
const toggleMin = () => panel.classList.toggle('minimized');
const closePanel = () => {
panel.classList.add('hidden');
const btn = document.getElementById(TOGGLE_BTN_ID);
if(btn) btn.style.display = 'flex';
};
panel.querySelector('#snip-min-btn').addEventListener('click', toggleMin);
header.addEventListener('dblclick', toggleMin);
panel.querySelector('#snip-close-btn').addEventListener('click', closePanel);
// 設定ボタン
panel.querySelector('#snip-config-btn').addEventListener('click', () => {
document.getElementById('cfg-font-size').value = configData.fontSize;
document.getElementById('cfg-opacity').value = configData.opacity;
configModal.style.display = 'flex';
});
// 検索機能
const searchInput = panel.querySelector('#snip-search');
searchInput.addEventListener('input', () => renderList(searchInput.value));
// インポート/エクスポート
panel.querySelector('#snip-export-btn').addEventListener('click', () => {
const blob = new Blob([JSON.stringify(snippetData, null, 2)], {type: "application/json"});
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url;
a.download = `ccfolia_snippets_${ROOM_ID}.json`;
a.click(); URL.revokeObjectURL(url);
});
panel.querySelector('#snip-import-btn').addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', e => {
const file = e.target.files[0]; if(!file) return;
const reader = new FileReader();
reader.onload = evt => {
try {
const d = JSON.parse(evt.target.result);
if(d.sections) {
snippetData = d; saveData(); renderList(searchInput.value);
alert("データを読み込みました。");
}
} catch(err) { alert("ファイル形式が正しくありません: " + err); }
};
reader.readAsText(file); fileInput.value = '';
});
// 追加ボタン
panel.querySelector('#snip-add-btn').addEventListener('click', () => openEditModal());
// --- リスト内クリックイベント移譲 ---
const listEl = panel.querySelector('#snip-list');
listEl.addEventListener('click', e => {
if(e.target.tagName === 'BUTTON') return;
// セクション開閉
const headerEl = e.target.closest('.snippet-section-header');
if (headerEl && !e.target.closest('.section-controls')) {
const sIdx = parseInt(headerEl.dataset.idx, 10);
if (!isNaN(sIdx)) {
snippetData.sections[sIdx].collapsed = !snippetData.sections[sIdx].collapsed;
saveData();
renderList(searchInput.value);
}
return;
}
// アイテムクリック (貼り付け)
const itemEl = e.target.closest('.snippet-item');
if(itemEl) {
const item = JSON.parse(decodeURIComponent(itemEl.dataset.json));
applySnippet(item);
}
});
// --- 編集モーダル関連 ---
const inSection = document.getElementById('snip-in-section');
const inChar = document.getElementById('snip-in-char');
const inLabel = document.getElementById('snip-in-label');
const inText = document.getElementById('snip-in-text');
const inTags = document.getElementById('snip-in-tags');
const dlSection = document.getElementById('snip-section-list');
function openEditModal(secIdx = null, itmIdx = null) {
editMode = { active: (secIdx !== null), secIdx, itmIdx };
dlSection.innerHTML = '';
snippetData.sections.forEach(s => {
const opt = document.createElement('option'); opt.value = s.name; dlSection.appendChild(opt);
});
if (editMode.active) {
const item = snippetData.sections[secIdx].items[itmIdx];
document.getElementById('snip-modal-title').textContent = "アイテム編集";
inSection.value = snippetData.sections[secIdx].name;
inChar.value = item.character || "";
inLabel.value = item.label;
inText.value = item.text;
inTags.value = (item.tags || []).join(', ');
document.getElementById('snip-edit-delete').style.visibility = 'visible';
} else {
document.getElementById('snip-modal-title').textContent = "新規追加";
inSection.value = snippetData.sections.length > 0 ? snippetData.sections[0].name : "";
inChar.value = ""; inLabel.value = ""; inText.value = ""; inTags.value = "";
document.getElementById('snip-edit-delete').style.visibility = 'hidden';
}
editModal.style.display = 'flex';
inLabel.focus();
}
window.openSnippetEditor = openEditModal; // グローバル公開
document.getElementById('snip-edit-cancel').addEventListener('click', () => editModal.style.display = 'none');
document.getElementById('snip-edit-save').addEventListener('click', () => {
const sName = inSection.value.trim() || "未分類";
const newItem = {
label: inLabel.value.trim() || "名称未設定",
text: inText.value,
character: inChar.value.trim(),
tags: inTags.value.split(/[ ,、]+/).map(t=>t.trim()).filter(t=>t)
};
// 編集モードなら元のアイテムを削除
if (editMode.active) {
snippetData.sections[editMode.secIdx].items.splice(editMode.itmIdx, 1);
// セクションが空になったらセクションごと消す
if(snippetData.sections[editMode.secIdx].items.length === 0) {
snippetData.sections.splice(editMode.secIdx, 1);
}
}
// 追加先のセクションを探す or 作る
let targetSec = snippetData.sections.find(s => s.name === sName);
if (!targetSec) {
targetSec = { name: sName, collapsed: false, items: [] };
snippetData.sections.push(targetSec);
}
targetSec.items.push(newItem);
saveData(); renderList(searchInput.value); editModal.style.display = 'none';
});
document.getElementById('snip-edit-delete').addEventListener('click', () => {
if(!confirm("本当に削除しますか?")) return;
if (editMode.active) {
snippetData.sections[editMode.secIdx].items.splice(editMode.itmIdx, 1);
if(snippetData.sections[editMode.secIdx].items.length === 0) {
snippetData.sections.splice(editMode.secIdx, 1);
}
saveData(); renderList(searchInput.value); editModal.style.display = 'none';
}
});
// --- 設定モーダル関連 ---
document.getElementById('cfg-close-btn').addEventListener('click', () => configModal.style.display = 'none');
document.getElementById('cfg-save-btn').addEventListener('click', () => {
configData.fontSize = parseInt(document.getElementById('cfg-font-size').value, 10) || 13;
configData.opacity = parseFloat(document.getElementById('cfg-opacity').value) || 0.95;
saveConfig();
configModal.style.display = 'none';
});
document.getElementById('cfg-reset-btn').addEventListener('click', () => {
if(confirm("【警告】\n現在のルームのデータをすべて削除し、初期状態に戻します。\n本当によろしいですか?")) {
snippetData = JSON.parse(JSON.stringify(DEFAULT_DATA));
saveData(); renderList();
configModal.style.display = 'none';
alert("初期化しました。");
}
});
}
// --- リスト描画 ---
function renderList(filterText = "") {
const listEl = document.getElementById('snip-list');
if (!listEl) return;
listEl.innerHTML = '';
// 空白区切りでAND検索
const keywords = filterText.toLowerCase().split(/[\s ]+/).filter(k => k.trim() !== "");
snippetData.sections.forEach((section, sIdx) => {
const filteredItems = section.items.map((item, iIdx) => ({...item, origIdx: iIdx})).filter(item => {
if (keywords.length === 0) return true;
return keywords.every(kw => {
const inLabel = item.label.toLowerCase().includes(kw);
const inText = item.text.toLowerCase().includes(kw);
const inChar = (item.character || "").toLowerCase().includes(kw);
const inTags = (item.tags || []).some(t => t.toLowerCase().includes(kw));
return inLabel || inText || inChar || inTags;
});
});
if (filteredItems.length === 0 && keywords.length > 0) return; // 検索ヒットなしならセクション非表示
// セクションヘッダー
const secHeader = document.createElement('div');
secHeader.className = 'snippet-section-header';
secHeader.dataset.idx = sIdx;
const isOpen = keywords.length > 0 ? true : !section.collapsed; // 検索中は強制オープン
const icon = isOpen ? '▼' : '▶';
let controlsHtml = '';
// 検索中以外は並び替えボタン表示
if (keywords.length === 0) {
const upBtn = sIdx > 0 ? `<button onclick="window.moveSnippetSection(${sIdx}, 'up')">▲</button>` : '';
const downBtn = sIdx < snippetData.sections.length - 1 ? `<button onclick="window.moveSnippetSection(${sIdx}, 'down')">▼</button>` : '';
controlsHtml = `<div class="section-controls">${upBtn}${downBtn}</div>`;
}
secHeader.innerHTML = `
<span class="snippet-section-title"><span class="icon">${icon}</span>${section.name}</span>
${controlsHtml}
`;
listEl.appendChild(secHeader);
// アイテム群
const itemsContainer = document.createElement('div');
itemsContainer.className = 'snippet-items-container' + (isOpen ? '' : ' collapsed');
filteredItems.forEach(item => {
const itemEl = document.createElement('div');
itemEl.className = 'snippet-item';
itemEl.dataset.json = encodeURIComponent(JSON.stringify(item));
const tagsHtml = (item.tags || []).map(t => `<span class="snippet-tag">${t}</span>`).join('');
const charHtml = item.character ? `<span class="char-badge">[${item.character}]</span>` : '';
itemEl.innerHTML = `
<div class="item-actions"><button class="edit-btn">編集</button></div>
<div class="header-line">${charHtml}<span class="label">${item.label}</span></div>
<span class="preview">${item.text}</span>
<div class="snippet-tags">${tagsHtml}</div>
`;
itemEl.querySelector('.edit-btn').addEventListener('click', (e) => {
e.stopPropagation();
window.openSnippetEditor(sIdx, item.origIdx);
});
itemsContainer.appendChild(itemEl);
});
listEl.appendChild(itemsContainer);
});
}
// --- トグルボタン注入 ---
function injectToggleBtn() {
if (document.getElementById(TOGGLE_BTN_ID)) return;
const header = document.querySelector('header') || document.querySelector('div[class*="MuiAppBar"]');
if (!header) return;
const btn = document.createElement('div');
btn.id = TOGGLE_BTN_ID; btn.textContent = "Snippet"; btn.title = "スニペットツールを表示";
// パネルが表示されていたらボタンは隠す
const panel = document.getElementById(UI_ID);
btn.style.display = (panel && !panel.classList.contains('hidden')) ? 'none' : 'flex';
btn.addEventListener('click', () => {
if (panel) { panel.classList.remove('hidden'); btn.style.display = 'none'; }
});
// 挿入位置: キャラクターボタン付近またはツールバー先頭
const charBtn = Array.from(header.querySelectorAll('button')).find(b =>
b.getAttribute('aria-label')?.includes('キャラクター') || b.title?.includes('キャラクター')
);
if (charBtn) {
charBtn.parentNode.insertBefore(btn, charBtn);
} else {
const toolbar = header.querySelector('div[class*="Toolbar"]') || header.lastElementChild;
if(toolbar) toolbar.insertBefore(btn, toolbar.firstChild);
}
}
// --- 初期化 ---
function init() {
loadData();
// ココフォリアのロード待ち
const timer = setInterval(() => {
// テキストエリアが存在すればロード完了とみなす
if (document.querySelector('textarea')) {
clearInterval(timer);
createUI();
injectToggleBtn();
renderList();
}
}, 1000);
}
init();
})();