// ==UserScript== // @name AI Tag Autocomplete for Mobile (Fixed Scroll) // @namespace http://tampermonkey.net/ // @version 0.4 // @description webおすすめ // @author ぶいぶい // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_addStyle // ==/UserScript== (function () { 'use strict'; // --- Constants --- const DB_NAME = 'AITagDB'; const DB_VERSION = 1; const STORE_NAME = 'tags'; const TRIGGER_CHARS = 3; // 3文字以上で検索開始 // --- Helper: Robust CSV Line Parser --- // カンマを含むフィールド ("Fate/Grand Order, (Game)") を正しく扱う function parseCSVLine(text) { const result = []; let startValueIndex = 0; let inQuotes = false; for (let i = 0; i < text.length; i++) { const char = text[i]; if (char === '"') { inQuotes = !inQuotes; } else if (char === ',' && !inQuotes) { let val = text.substring(startValueIndex, i).trim(); if (val.startsWith('"') && val.endsWith('"')) { val = val.slice(1, -1).replace(/""/g, '"'); } result.push(val); startValueIndex = i + 1; } } let val = text.substring(startValueIndex).trim(); if (val.startsWith('"') && val.endsWith('"')) { val = val.slice(1, -1).replace(/""/g, '"'); } result.push(val); return result; } // --- Snippet Manager (GM storage) --- class SnippetManager { constructor() { this.snippets = GM_getValue('snippets', {}); } save(key, value) { this.snippets[key] = value; GM_setValue('snippets', this.snippets); } remove(key) { delete this.snippets[key]; GM_setValue('snippets', this.snippets); } get(key) { return this.snippets[key]; } search(query) { const keys = Object.keys(this.snippets); const matched = keys.filter(k => k.startsWith(query)); return matched.map(k => ({ name: k, value: this.snippets[k], type: 'snippet', count: 99999999 // スニペットは常に最上位 })); } getAll() { return this.snippets; } } // --- IndexedDB Manager --- class IndexedDBManager { constructor() { this.db = null; } async init() { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = (e) => reject(e.target.error); request.onsuccess = (e) => { this.db = e.target.result; resolve(this.db); }; request.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains(STORE_NAME)) { const objectStore = db.createObjectStore(STORE_NAME, { keyPath: 'name' }); objectStore.createIndex('count', 'count', { unique: false }); } }; }); } async importCSV(csvText) { if (!this.db) await this.init(); return new Promise((resolve, reject) => { const transaction = this.db.transaction([STORE_NAME], 'readwrite'); const store = transaction.objectStore(STORE_NAME); transaction.oncomplete = () => resolve(); transaction.onerror = (e) => reject(e.target.error); const lines = csvText.split(/\r?\n/); let count = 0; lines.forEach(line => { if (!line.trim()) return; const parts = parseCSVLine(line); if (parts.length >= 1) { const name = parts[0]; const category = parseInt(parts[1]) || 0; const postCount = parseInt(parts[2]) || 0; const description = parts.slice(3).join(' '); store.put({ name: name, category: category, count: postCount, description: description }); count++; } }); console.log(`Imported ${count} tags.`); }); } async search(query, limit = 30) { if (!this.db) await this.init(); return new Promise((resolve) => { const transaction = this.db.transaction([STORE_NAME], 'readonly'); const store = transaction.objectStore(STORE_NAME); const results = []; const lowerQuery = query.toLowerCase(); const request = store.openCursor(); request.onsuccess = (e) => { const cursor = e.target.result; if (cursor) { const val = cursor.value; if (val.name.toLowerCase().startsWith(lowerQuery) || (val.description && val.description.toLowerCase().includes(lowerQuery))) { results.push(val); } if (results.length < limit * 3) { cursor.continue(); } else { resolve(this.sortResults(results, limit)); } } else { resolve(this.sortResults(results, limit)); } }; request.onerror = () => resolve([]); }); } sortResults(results, limit) { return results.sort((a, b) => b.count - a.count).slice(0, limit); } async clear() { if (!this.db) await this.init(); const tx = this.db.transaction([STORE_NAME], 'readwrite'); tx.objectStore(STORE_NAME).clear(); return new Promise(r => tx.oncomplete = r); } } // --- Settings UI --- class SettingsUI { constructor(dbManager, snippetManager) { this.dbManager = dbManager; this.snippetManager = snippetManager; this.overlay = null; } createOverlay() { if (this.overlay) return; this.overlay = document.createElement('div'); Object.assign(this.overlay.style, { position: 'fixed', top: '0', left: '0', width: '100%', height: '100%', backgroundColor: 'rgba(0,0,0,0.8)', zIndex: '100000', display: 'none', justifyContent: 'center', alignItems: 'flex-start', paddingTop: '50px', color: '#fff', fontFamily: 'sans-serif', fontSize: '14px' }); const container = document.createElement('div'); Object.assign(container.style, { backgroundColor: '#222', padding: '20px', borderRadius: '8px', width: '90%', maxWidth: '400px', maxHeight: '80%', overflowY: 'auto', boxShadow: '0 0 20px rgba(0,0,0,0.5)' }); const title = document.createElement('h3'); title.textContent = 'AI Tag Tools Config'; title.style.marginTop = '0'; container.appendChild(title); // --- CSV Section --- const hCsv = document.createElement('h4'); hCsv.textContent = '1. Tag Dictionary (CSV)'; container.appendChild(hCsv); const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = '.csv'; fileInput.style.width = '100%'; container.appendChild(fileInput); const btnRow = document.createElement('div'); btnRow.style.marginTop = '10px'; const importBtn = document.createElement('button'); importBtn.textContent = 'Load CSV'; importBtn.onclick = () => { const file = fileInput.files[0]; if (!file) return alert('Please select a CSV file.'); const reader = new FileReader(); importBtn.disabled = true; importBtn.textContent = 'Loading...'; reader.onload = async (e) => { await this.dbManager.importCSV(e.target.result); alert('CSV Imported Successfully!'); importBtn.disabled = false; importBtn.textContent = 'Load CSV'; }; reader.readAsText(file); }; btnRow.appendChild(importBtn); const clearBtn = document.createElement('button'); clearBtn.textContent = 'Clear DB'; clearBtn.style.marginLeft = '10px'; clearBtn.onclick = async () => { if(confirm('Delete all tags?')) { await this.dbManager.clear(); alert('Database cleared.'); } }; btnRow.appendChild(clearBtn); container.appendChild(btnRow); // --- Snippet Section --- const hSnip = document.createElement('h4'); hSnip.textContent = '2. Snippets'; container.appendChild(hSnip); const snipKey = document.createElement('input'); snipKey.placeholder = 'Key (e.g. qm)'; snipKey.style.width = '30%'; snipKey.style.marginRight = '2%'; const snipVal = document.createElement('input'); snipVal.placeholder = 'Value (e.g. masterpiece...)'; snipVal.style.width = '65%'; const addSnipBtn = document.createElement('button'); addSnipBtn.textContent = 'Add Snippet'; addSnipBtn.style.marginTop = '5px'; addSnipBtn.style.width = '100%'; const snipList = document.createElement('div'); snipList.style.cssText = "max-height: 100px; overflow-y: auto; margin-top:10px; background:#333; padding:5px;"; const renderSnippets = () => { snipList.innerHTML = ''; const all = this.snippetManager.getAll(); Object.keys(all).forEach(k => { const row = document.createElement('div'); row.style.borderBottom = '1px solid #444'; row.textContent = `${k} : ${all[k].substring(0, 20)}...`; row.onclick = () => { if(confirm(`Delete snippet "${k}"?`)) { this.snippetManager.remove(k); renderSnippets(); } }; snipList.appendChild(row); }); }; addSnipBtn.onclick = () => { const k = snipKey.value.trim(); const v = snipVal.value.trim(); if(k && v) { this.snippetManager.save(k, v); snipKey.value = ''; snipVal.value = ''; renderSnippets(); } }; container.appendChild(snipKey); container.appendChild(snipVal); container.appendChild(addSnipBtn); container.appendChild(snipList); renderSnippets(); const closeBtn = document.createElement('button'); closeBtn.textContent = 'Close Settings'; closeBtn.style.width = '100%'; closeBtn.style.marginTop = '20px'; closeBtn.style.padding = '10px'; closeBtn.onclick = () => this.hide(); container.appendChild(closeBtn); this.overlay.appendChild(container); document.body.appendChild(this.overlay); } show() { if (!this.overlay) this.createOverlay(); this.overlay.style.display = 'flex'; } hide() { if (this.overlay) this.overlay.style.display = 'none'; } } // --- Suggestion UI --- class SuggestionUI { constructor(snippetManager) { this.snippetManager = snippetManager; this.container = null; this.visible = false; this.activeInput = null; this.initStyles(); } initStyles() { GM_addStyle(` #ai-tag-ui { position: fixed; z-index: 999999; background: #121212; color: #e0e0e0; border-top: 1px solid #333; box-shadow: 0 -4px 12px rgba(0,0,0,0.5); font-family: sans-serif; display: none; left: 0; width: 100%; box-sizing: border-box; /* --- Scroll Fixes --- */ display: flex; flex-direction: row; align-items: center; overflow-x: auto; overflow-y: hidden; touch-action: pan-x; -webkit-overflow-scrolling: touch; padding: 8px 0; scrollbar-width: none; } #ai-tag-ui::-webkit-scrollbar { display: none; } .ai-tag-chip { flex: 0 0 auto; display: inline-block; padding: 6px 12px; margin: 0 4px; border-radius: 4px; font-size: 14px; cursor: pointer; background: #2c2c2c; border-left: 3px solid #555; user-select: none; } .ai-tag-chip:active { background: #444; } /* Colors */ .ai-tag-snip { border-left-color: #ffeb3b; background: #424220; } .ai-tag-c0 { border-left-color: #9e9e9e; } /* General */ .ai-tag-c1 { border-left-color: #f44336; } /* Artist */ .ai-tag-c3 { border-left-color: #e040fb; } /* Copyright */ .ai-tag-c4 { border-left-color: #00e676; } /* Character */ .ai-tag-c5 { border-left-color: #ff9800; } /* Meta */ .ai-tag-count { font-size: 0.75em; opacity: 0.7; margin-left: 4px; } `); } createContainer() { if (this.container) return; this.container = document.createElement('div'); this.container.id = 'ai-tag-ui'; document.body.appendChild(this.container); this.container.addEventListener('mousedown', e => e.preventDefault()); } render(results, inputElement) { if (!this.container) this.createContainer(); this.activeInput = inputElement; this.container.innerHTML = ''; const spacer = document.createElement('div'); spacer.style.width = '4px'; spacer.style.flex = '0 0 auto'; this.container.appendChild(spacer); results.forEach(item => { const el = document.createElement('div'); if (item.type === 'snippet') { el.className = 'ai-tag-chip ai-tag-snip'; el.textContent = `★ ${item.name}`; } else { el.className = `ai-tag-chip ai-tag-c${item.category}`; el.textContent = item.name.replace(/_/g, ' '); if(item.count > 0) { const span = document.createElement('span'); span.className = 'ai-tag-count'; span.textContent = this.fmtCount(item.count); el.appendChild(span); } } el.onclick = (e) => { e.preventDefault(); e.stopPropagation(); const insertVal = item.type === 'snippet' ? item.value : item.name; this.insert(insertVal); }; this.container.appendChild(el); }); this.show(); } fmtCount(n) { if (n >= 1000000) return (n/1000000).toFixed(1) + 'M'; if (n >= 1000) return (n/1000).toFixed(0) + 'k'; return n; } insert(text) { if (!this.activeInput) return; const field = this.activeInput; const val = field.value; const cur = field.selectionStart; const textBefore = val.substring(0, cur); const lastComma = textBefore.lastIndexOf(','); const startReplace = lastComma === -1 ? 0 : lastComma + 1; // 挿入テキストの整形 let cleanText = text.replace(/_/g, ' '); const prefix = (startReplace > 0 && val[startReplace] !== ' ') ? ' ' : ''; const newPart = prefix + cleanText + ', '; const newTextBefore = val.substring(0, startReplace) + newPart; const textAfter = val.substring(cur); field.value = newTextBefore + textAfter; const newPos = newTextBefore.length; field.setSelectionRange(newPos, newPos); field.focus(); this.hide(); } show() { this.visible = true; this.container.style.display = 'flex'; this.updatePos(); } hide() { this.visible = false; if (this.container) this.container.style.display = 'none'; } updatePos() { if (!this.visible || !this.container) return; const vv = window.visualViewport; if (vv) { // Visual Viewportの高さ + オフセット = 表示領域の下端 // そこからコンテナの高さを引いて、キーボードの真上に配置 const top = vv.offsetTop + vv.height - this.container.offsetHeight; this.container.style.top = `${top}px`; } else { this.container.style.bottom = '0'; } } } // --- Input Logic --- class InputHandler { constructor(dbManager, snippetManager, suggestionUI) { this.db = dbManager; this.snip = snippetManager; this.ui = suggestionUI; this.timer = null; if (window.visualViewport) { const update = () => this.ui.updatePos(); window.visualViewport.addEventListener('resize', update); window.visualViewport.addEventListener('scroll', update); } document.addEventListener('input', e => this.onInput(e)); document.addEventListener('click', e => { if (this.ui.visible && !this.ui.container.contains(e.target) && e.target !== this.ui.activeInput) { this.ui.hide(); } }); } onInput(e) { const t = e.target; if (t.tagName !== 'TEXTAREA' && (t.tagName !== 'INPUT' || t.type !== 'text')) return; if (this.timer) clearTimeout(this.timer); this.timer = setTimeout(async () => { const val = t.value; const cur = t.selectionStart; const prev = val.substring(0, cur); const lastComma = prev.lastIndexOf(','); const word = prev.substring(lastComma + 1).trim(); if (word.length >= TRIGGER_CHARS) { const snipRes = this.snip.search(word); const tagRes = await this.db.search(word); const combined = [...snipRes, ...tagRes]; if (combined.length > 0) { this.ui.render(combined, t); } else { this.ui.hide(); } } else { this.ui.hide(); } }, 200); } } // --- Boot --- const snipMgr = new SnippetManager(); const dbMgr = new IndexedDBManager(); const settings = new SettingsUI(dbMgr, snipMgr); const ui = new SuggestionUI(snipMgr); new InputHandler(dbMgr, snipMgr, ui); GM_registerMenuCommand('Tag Autocomplete Settings', () => settings.show()); console.log('AI Tag Autocomplete v0.4 Ready'); })();