// ==UserScript== // @name タグオートコンプリート for android // @namespace http://tampermonkey.net/ // @version 2.1 // @description NAI,pixai向けタグオートコンプリート // @author ぶいぶい // @match https://novelai.net/* // @match https://pixai.art/* // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_addStyle // @license MIT // ==/UserScript== (function () { 'use strict'; // --- Constants --- const DB_NAME = 'AITagDB'; const DB_VERSION = 1; const STORE_NAME = 'tags'; const TRIGGER_CHARS = 3; // --- Helper: ContentEditable & Insertion Logic --- const EditorUtils = { isEditable(el) { if (!el) return false; if (el.tagName === 'TEXTAREA') return true; if (el.tagName === 'INPUT' && el.type === 'text') return true; if (el.isContentEditable) return true; return false; }, getValue(el) { if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') return el.value; return el.textContent; }, getCursorPos(el) { try { if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') return el.selectionStart; } catch(e) { return 0; } let pos = 0; const sel = window.getSelection(); if (sel.rangeCount > 0) { const range = sel.getRangeAt(0); const preCaretRange = range.cloneRange(); preCaretRange.selectNodeContents(el); preCaretRange.setEnd(range.endContainer, range.endOffset); pos = preCaretRange.toString().length; } return pos; }, replaceLastWord(el, wordLengthToReplace, textToInsert) { el.focus(); if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') { const start = el.selectionStart - wordLengthToReplace; const end = el.selectionEnd; const safeStart = Math.max(0, start); if (typeof el.setRangeText === 'function') { el.setRangeText(textToInsert, safeStart, end, 'end'); el.dispatchEvent(new Event('input', { bubbles: true, composed: true })); } else { el.setSelectionRange(safeStart, end); document.execCommand('insertText', false, textToInsert); } } else { // ContentEditable document.execCommand('insertText', false, textToInsert); } } }; // --- Helper: CSV Parser --- 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 --- 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; this.cache = [ { name: 'test_tag_sample', category: 0, count: 999999, description: 'Script Works!' }, { name: '1girl', category: 4, count: 500000, description: 'One girl' }, { name: 'masterpiece', category: 5, count: 400000, description: 'Quality' } ]; this.useMemory = GM_getValue('useMemory', true); } setMode(useMemory) { this.useMemory = useMemory; if (this.useMemory && this.db) this.loadCacheToMemory(); else if (!this.useMemory) this.cache = null; } async init() { return new Promise((resolve) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => resolve(null); request.onsuccess = (e) => { this.db = e.target.result; if (this.useMemory) this.loadCacheToMemory(); resolve(this.db); }; request.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains(STORE_NAME)) { const store = db.createObjectStore(STORE_NAME, { keyPath: 'name' }); store.createIndex('count', 'count', { unique: false }); } }; }); } async loadCacheToMemory() { if (!this.db) return; const tx = this.db.transaction([STORE_NAME], 'readonly'); const store = tx.objectStore(STORE_NAME); const req = store.getAll(); req.onsuccess = () => { if (req.result && req.result.length > 0) this.cache = req.result; }; } async importCSV(csvText) { if (!this.db) await this.init(); return new Promise((resolve, reject) => { const tx = this.db.transaction([STORE_NAME], 'readwrite'); const store = tx.objectStore(STORE_NAME); tx.oncomplete = () => { if (this.useMemory) this.loadCacheToMemory(); resolve(); }; tx.onerror = (e) => reject(e.target.error); const lines = csvText.split(/\r?\n/); lines.forEach(line => { if (!line.trim()) return; const parts = parseCSVLine(line); if (parts.length >= 1) { store.put({ name: parts[0], category: parseInt(parts[1]) || 0, count: parseInt(parts[2]) || 0, description: parts.slice(3).join(' ') }); } }); }); } async search(query, useDesc, limit = 30) { if (!this.db) await this.init(); if (this.useMemory && this.cache) { const lowerQuery = query.toLowerCase(); const results = []; for (let i = 0; i < this.cache.length; i++) { const val = this.cache[i]; const valName = val.name.toLowerCase(); const matchNamePart = valName.includes(lowerQuery); const matchDesc = useDesc && val.description && val.description.toLowerCase().includes(lowerQuery); if (matchNamePart || matchDesc) { let item = val; if (valName === lowerQuery) item = { ...val, count: val.count + 100000000 }; results.push(item); } } return results.sort((a, b) => b.count - a.count).slice(0, limit); } else { return new Promise((resolve) => { const tx = this.db.transaction([STORE_NAME], 'readonly'); const store = tx.objectStore(STORE_NAME); const results = []; const lowerQuery = query.toLowerCase(); const req = store.openCursor(); req.onsuccess = (e) => { const cursor = e.target.result; if (cursor) { const val = cursor.value; const valName = val.name.toLowerCase(); const matchNamePart = valName.includes(lowerQuery); const matchDesc = useDesc && val.description && val.description.toLowerCase().includes(lowerQuery); if (matchNamePart || matchDesc) { if (valName === lowerQuery) val.count += 100000000; results.push(val); } if (results.length < limit * 4) cursor.continue(); else resolve(results.sort((a, b) => b.count - a.count).slice(0, limit)); } else { resolve(results.sort((a, b) => b.count - a.count).slice(0, limit)); } }; req.onerror = () => resolve([]); }); } } } // --- Suggestion UI --- class SuggestionUI { constructor(snipMgr) { this.snipMgr = snipMgr; this.container = null; this.visible = false; this.activeInput = null; this.lastQueryLength = 0; this.rowCount = 1; this.initStyles(); } initStyles() { GM_addStyle(` #ai-tag-ui { position: fixed; z-index: 2147483647; background: #121212; color: #e0e0e0; border-top: 1px solid #333; box-shadow: 0 -4px 12px rgba(0,0,0,0.5); display: none; left: 0; width: 100%; box-sizing: border-box; display: grid; grid-auto-flow: column; gap: 4px 8px; padding: 8px; overflow-x: auto; overflow-y: hidden; touch-action: pan-x; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding-bottom: max(8px, env(safe-area-inset-bottom, 0px)); } .ai-tag-chip { display: inline-block; padding: 6px 12px; margin: 0; border-radius: 4px; font-size: 14px; cursor: pointer; background: #2c2c2c; border-left: 3px solid #555; user-select: none; white-space: nowrap; height: 32px; line-height: 20px; box-sizing: border-box; } .ai-tag-snip { border-left-color: #ffeb3b; background: #424220; } .ai-tag-c0 { border-left-color: #9e9e9e; } .ai-tag-c1 { border-left-color: #f44336; } .ai-tag-c3 { border-left-color: #e040fb; } .ai-tag-c4 { border-left-color: #00e676; } .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()); this.container.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const chip = e.target.closest('.ai-tag-chip'); if (chip) { const val = chip.dataset.value; if (val) this.insert(val); } }); } render(results, inputElement, queryLength, rowCount) { if (!this.container) this.createContainer(); this.activeInput = inputElement; this.lastQueryLength = queryLength; if (this.rowCount !== rowCount) { this.rowCount = rowCount; this.container.style.gridTemplateRows = `repeat(${rowCount}, auto)`; } let html = ''; results.forEach(item => { const typeClass = item.type === 'snippet' ? 'ai-tag-snip' : `ai-tag-c${item.category}`; const dispName = item.name.replace(/_/g, ' '); const val = item.type === 'snippet' ? item.value : item.name; const countHtml = item.count > 0 ? `${(item.count/1000).toFixed(0)}k` : ''; const icon = item.type === 'snippet' ? '★ ' : ''; html += `
${icon}${dispName}${countHtml}
`; }); this.container.innerHTML = html; this.show(); } insert(text) { if (!this.activeInput) return; const el = this.activeInput; const cleanText = text.replace(/_/g, ' '); const finalText = cleanText + ', '; EditorUtils.replaceLastWord(el, this.lastQueryLength, finalText); this.hide(); } show() { this.visible = true; this.container.style.display = 'grid'; 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) { // Iframe内での位置計算。Iframeが画面いっぱいならこれでOK const top = vv.offsetTop + vv.height - this.container.offsetHeight; this.container.style.top = `${top}px`; } else { this.container.style.top = '0'; } } } // --- Input Logic (Capture & Shadow DOM) --- class InputHandler { constructor(dbMgr, snipMgr, ui) { this.db = dbMgr; this.snip = snipMgr; this.ui = ui; this.timer = null; this.getConfig = () => ({ searchDesc: GM_getValue('searchDesc', false), rowCount: GM_getValue('rowCount', 1) }); if (window.visualViewport) { const up = () => this.ui.updatePos(); window.visualViewport.addEventListener('resize', up); window.visualViewport.addEventListener('scroll', up); } // Capture + Shadow DOM Support ['input', 'compositionend'].forEach(evt => { document.addEventListener(evt, e => this.onInput(e), { capture: true, passive: true }); }); // Focus Indicator document.addEventListener('focus', e => { const t = e.composedPath ? e.composedPath()[0] : e.target; if (EditorUtils.isEditable(t)) { t.style.outline = "3px solid rgba(255, 0, 0, 0.3)"; setTimeout(() => { t.style.outline = "none"; }, 3000); } }, { capture: true, passive: true }); document.addEventListener('click', e => { if (this.ui.visible && !this.ui.container.contains(e.target) && e.target !== this.ui.activeInput) { this.ui.hide(); } }, { capture: true }); } onInput(e) { const t = e.composedPath ? e.composedPath()[0] : e.target; if (!EditorUtils.isEditable(t)) return; if (e.isComposing) return; if (this.timer) clearTimeout(this.timer); this.timer = setTimeout(async () => { const val = EditorUtils.getValue(t); const cur = EditorUtils.getCursorPos(t); 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 config = this.getConfig(); const tagRes = await this.db.search(word, config.searchDesc); const combined = [...snipRes, ...tagRes]; if (combined.length > 0) this.ui.render(combined, t, word.length, config.rowCount); else this.ui.hide(); } else { this.ui.hide(); } }, 100); } } // --- Boot --- const snipMgr = new SnippetManager(); const dbMgr = new IndexedDBManager(); // const settings = new SettingsUI(dbMgr, snipMgr); // Uncomment when SettingsUI is included const ui = new SuggestionUI(snipMgr); dbMgr.init(); new InputHandler(dbMgr, snipMgr, ui); // settings is optional in InputHandler logic above // GM_registerMenuCommand('Tag Settings', () => settings.show()); console.log('AI Tag Autocomplete v2.1 (HF/Iframe) Ready'); })();