Save your favourite search queries for easy retrieval. Aims to enhance searching experience. Made for ImageBoard sites, but suitable for any websites with a search bar.
// ==UserScript== // @name Save Your Searches // @namespace http://tampermonkey.net/ // @version 3.2 // @description Save your favourite search queries for easy retrieval. Aims to enhance searching experience. Made for ImageBoard sites, but suitable for any websites with a search bar. // @author php // @match *://rule34.xxx/* // @match *://danbooru.donmai.us/* // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant unsafeWindow // @require https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js // @license MIT // ==/UserScript== (function() { 'use strict'; const Sortable = window.Sortable; // ========================================== // [Module 1] EventBus // ========================================== const EventBus = { events: {}, on(event, listener) { if (!this.events[event]) this.events[event] = []; this.events[event].push(listener); }, emit(event, data) { if (this.events[event]) { this.events[event].forEach(listener => listener(data)); } } }; // ========================================== // [Module 2] Helper (The Single Source of Truth) // ========================================== const Helper = { // --- 2.1 Configuration Data --- Config: { DEFAULT_DATA: { settings: { version: "3.1.0", lastUpdate: new Date().toISOString() }, profiles: { "rule34.xxx": { name: "Rule34", selectors: { searchBar: "input[name='tags']", insertPoint: "div.tag-search" }, cheatsheetUrl: "https://rule34.xxx/index.php?page=help&topic=cheatsheet", showCheatsheet: false, wikiUrl: "https://rule34.xxx/index.php?page=wiki&s=list", showWiki: false, defaultOpenQueryBox: true, showSettingsBtn: true, delimiter: " ", splitRegex: "\\s+", groups: [ { id: "g1", label: "Sort", queries: ["sort:score:desc", "sort:id:desc"] }, { id: "g2", label: "Meta", queries: ["( video ~ animated )", "( 3d ~ 3d_animation )", "( ai ~ ai_generated ~ ai_assisted )"] } ] }, "danbooru.donmai.us": { name: "Danbooru", selectors: { searchBar: "#tags", insertPoint: "#search-box" }, cheatsheetUrl: "https://danbooru.donmai.us/wiki_pages/help:cheatsheet", showCheatsheet: false, wikiUrl: "https://danbooru.donmai.us/wiki_pages", showWiki: false, defaultOpenQueryBox: true, showSettingsBtn: true, delimiter: " ", splitRegex: "\\s+", groups: [ { id: "g3", label: "Sort", queries: ["order:favcount", "order:score", "order:id_desc", "age:..1w"] }, { id: "g4", label: "Meta", queries: ["~video ~animated", "~ai-generated ~ai-assisted"] } ] } } } }, // --- 2.2 Styles Definition --- Styles: { PANEL_CSS: ` .panel { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 95%; max-width: 400px; /* Caps the size for desktop */ max-height: 90vh; background: rgba(255,255,255,0.90); backdrop-filter: blur(15px); border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); z-index: 20000; display: none; flex-direction: column; font-family: system-ui, sans-serif; border: 1px solid rgba(0,0,0,0.1); } .header { padding: 15px; background: rgba(0,0,0,0.05); cursor: move; display: flex; justify-content: center; align-items: center; border-bottom: 1px solid rgba(0,0,0,0.1); font-weight: bold; } .content { padding: 15px; overflow-y: auto; flex: 1; } .footer { padding: 12px; display: flex; flex-direction: column; gap: 8px; background: #f9f9f9; border-top: 1px solid #eee; border-radius: 0 0 12px 12px; } .footer-row { display: flex; gap: 8px; width: 100%; } .section-title { font-size: 11px; font-weight: bold; color: #888; text-transform: uppercase; margin: 15px 0 8px 0; } .config-field { margin-bottom: 10px; } .config-field label { display: block; font-size: 11px; margin-bottom: 4px; color: #666; font-weight: 600; } .config-field input[type="text"], .config-field select { width: 100%; box-sizing: border-box; padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; outline: none; } .checkbox-group { display: flex; flex-direction: column; gap: 8px; margin-top: 5px; } .checkbox-field { display: flex; align-items: center; gap: 6px; font-size: 12px; cursor: pointer; user-select: none; color: #444; } .card { background: white; padding: 12px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #eee; position: relative; } .sub-card { background: #f8f9fa; padding: 12px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #eaeced; position: relative; } .sub-card:last-child { margin-bottom: 0; } /* Removes extra space at the bottom of the container */ .card-title { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } /* 幽靈輸入框樣式:平時像文字,互動時顯現 */ .group-label-input { background: transparent; border: none; /* Show a very faint line by default for mobile users */ border-bottom: 1px dashed rgba(0,0,0,0.1); font-weight: bold; font-size: 13px; color: #444; width: 100%; outline: none; transition: all 0.2s; cursor: text; padding: 2px 0; } .group-label-input:focus { border-bottom: 1px solid #2196f3; background: rgba(33, 150, 243, 0.05); } /* 排序模式下讓輸入框看起來像標籤,且不可點擊 */ .group-label-input:disabled { cursor: default; border: none; } .tag-chip { display: inline-flex; align-items: center; background: #f0f0f0; padding: 4px 10px; border-radius: 6px; margin: 3px; font-size: 12px; border: 1px solid #e0e0e0; color: #000;} .tag-del { margin-left: 6px; color: #f44336; cursor: pointer; font-weight: bold; } .sortable-ghost { opacity: 0.4; border: 2px dashed #2196f3 !important; background: #e3f2fd !important; } .drag-handle-group { cursor: grab; color: #bbb; font-size: 22px; padding: 15px; margin: -15px 0 -15px -15px; /* Negative margin offsets the padding */ display: inline-flex; align-items: center; justify-content: center; } .drag-handle-tag { cursor: grab; color: #ccc; font-size: 18px; padding: 10px; margin: -10px 0 -10px -10px; /* Negative margin offsets the padding */ display: inline-flex; align-items: center; justify-content: center; } /* Change color when dragging to show it's "active" */ .drag-handle-group:active, .drag-handle-tag:active { color: #2196f3; } .btn { padding: 8px 16px; border-radius: 6px; cursor: pointer; border: none; font-size: 13px; font-weight: 500; transition: all 0.2s; display: inline-flex; align-items: center; justify-content: center; } .btn-add { background: #4caf50; color: white; margin-top: 5px; width: 100%; } .btn-current { background: #ff9800; color: white; } .btn:hover, .btn:active { filter: brightness(0.9); } .btn-save { background: #2196f3; color: white; } /* [Modified] Rearrange button style adjusted for inline title placement */ .btn-rearrange { background: #90a4ae; color: white; padding: 4px 10px; font-size: 11px; border-radius: 4px; } .btn-rearrange.active { background: #e91e63; } .btn-reset { background: #f44336; color: white; margin-right: auto; } .btn-data { background: #607d8b; color: white; flex: 1; font-size: 12px; padding: 6px; } .modal-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 20001; border-radius: 12px; } .modal-box { background: white; padding: 20px; border-radius: 10px; width: 280px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); text-align: center; } .modal-title { font-size: 14px; font-weight: bold; margin-bottom: 15px; color: #333; } .modal-buttons { display: flex; flex-direction: column; gap: 10px; } .btn-modal { padding: 10px; border-radius: 6px; cursor: pointer; border: none; font-size: 13px; font-weight: bold; text-align: center; transition: opacity 0.2s; } .btn-modal:hover { opacity: 0.8; } .btn-modal-current { background: #2196f3; color: white; } .btn-modal-all { background: #4caf50; color: white; } .btn-modal-cancel { background: #f44336; color: white; } .warning-box { background: #fff3cd; color: #856404; padding: 10px; border-radius: 8px; border: 1px solid #ffeeba; margin-bottom: 15px; font-size: 12px; line-height: 1.4; } `, INJECTOR_WRAPPER: "margin: 10px 0; font-family: sans-serif; display: flex; gap: 8px; flex-wrap: wrap; justify-content: center;", TRIGGER_BTN: "padding: 4px 6px; background: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500;", SETTINGS_BTN: "padding: 4px 6px; background: #3f51b5; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500;", CHEATSHEET_BTN: "padding: 4px 6px; background: #009688; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; text-decoration: none; font-weight: 500;", WIKI_BTN: "padding: 4px 6px; background: #ff5722; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; text-decoration: none; font-weight: 500;", QUERY_BOX: "width: 100%; margin-top: 5px; display: flex; flex-direction: column; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.05);", GROUP_CARD: (isLast) => `padding: 12px; border-bottom: ${isLast ? 'none' : '2px solid #eee'}`, GROUP_TITLE: "font-size: 11px; font-weight: bold; color: #888; text-transform: uppercase; margin-bottom: 8px;", TAG_ITEM: "background: white; color: #000; border: 1px solid #ced4da; padding: 8px 12px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: all 0.15s ease;", TAG_ITEM_ACTIVE: "background: #e3f2fd; border: 1px solid #2196f3; color: #0d47a1; padding: 8px 12px; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: bold; transition: all 0.15s ease;" }, // --- 2.3 HTML UI Components --- Templates: { PANEL_SHELL: (contentHTML) => ` <style>${Helper.Styles.PANEL_CSS}</style> <div class="panel" id="main-panel"> <div class="header" id="drag-handle">Save Your Searches</div> <div class="content" id="settings-content">${contentHTML}</div> <div class="footer"> <div class="footer-row"> <button class="btn btn-data" id="btn-export-trigger">📤 Export JSON</button> <button class="btn btn-data" id="btn-import-trigger">📥 Import JSON</button> <input type="file" id="sys-import-file" style="display:none" accept=".json"> </div> <div class="footer-row"> <button class="btn btn-reset" id="btn-reset">Reset</button> <button class="btn" id="btn-cancel">Cancel</button> <button class="btn btn-save" id="btn-save">Save & Apply</button> </div> </div> <div class="modal-overlay" id="action-modal"> <div class="modal-box"> <div class="modal-title" id="modal-title">Select Scope</div> <div class="modal-buttons"> <button class="btn-modal btn-modal-current" id="modal-btn-current">Only Current Website</button> <button class="btn-modal btn-modal-all" id="modal-btn-all">All Websites</button> <button class="btn-modal btn-modal-cancel" id="modal-btn-cancel">Cancel</button> </div> </div> </div> </div> `, PROFILE_SECTION: (profiles, currentHost) => ` <div class="section-title">Profile (Website)</div> <div class="config-field"> <select id="profile-selector"> ${Object.keys(profiles).map(h => `<option value="${h}" ${h === currentHost ? 'selected' : ''}>${h}</option>`).join('')} </select> </div> `, SITE_SELECTORS_SECTION: (profile, isRearranging) => ` <div class="section-title">Site Selectors & Tag Delimiter</div> <div class="card"> <div class="config-field"><label>Search Bar Selector</label><input type="text" value="${profile.selectors.searchBar}" id="input-sel-search" ${isRearranging?'disabled':''}></div> <div class="config-field"><label>Insert Point Selector</label><input type="text" value="${profile.selectors.insertPoint}" id="input-sel-insert" ${isRearranging?'disabled':''}></div> <hr style="border: 0; border-top: 1px solid #eee; margin: 12px 0;"> <div style="display:flex; gap:10px;"> <div class="config-field" style="flex:1;"><label>Tag Delimiter Character</label><input type="text" value="${profile.delimiter}" id="input-delimiter" ${isRearranging?'disabled':''}></div> <div class="config-field" style="flex:1;"><label>Tag Delimiter Regex</label><input type="text" value="${profile.splitRegex}" id="input-split-regex" ${isRearranging?'disabled':''}></div> </div> </div> `, SITE_RESOURCES_SECTION: (profile, isRearranging) => ` <div class="section-title">Site Resources & Buttons</div> <div class="card"> <div class="config-field"><label>Cheatsheet URL</label><input type="text" value="${profile.cheatsheetUrl || ''}" id="input-cheatsheet-url" ${isRearranging?'disabled':''}></div> <div class="config-field"><label>Wiki URL</label><input type="text" value="${profile.wikiUrl || ''}" id="input-wiki-url" ${isRearranging?'disabled':''}></div> <hr style="border: 0; border-top: 1px solid #eee; margin: 12px 0;"> <div class="checkbox-group"> <label class="checkbox-field"><input type="checkbox" id="check-show-cheatsheet" ${profile.showCheatsheet ? 'checked' : ''} ${isRearranging?'disabled':''}> Show Cheatsheet</label> <label class="checkbox-field"><input type="checkbox" id="check-show-wiki" ${profile.showWiki ? 'checked' : ''} ${isRearranging?'disabled':''}> Show Wiki</label> <label class="checkbox-field"><input type="checkbox" id="check-show-settings" ${profile.showSettingsBtn !== false ? 'checked' : ''} ${isRearranging?'disabled':''}> Show Settings Button</label> <label class="checkbox-field"><input type="checkbox" id="check-default-open" ${profile.defaultOpenQueryBox ? 'checked' : ''} ${isRearranging?'disabled':''}> Open Saved Searches Box by Default</label> </div> </div> `, TAG_GROUP_HTML: (group, gIdx, isRearranging) => ` <div class="sub-card sortable-group-item" data-gidx="${gIdx}"> <div class="card-title"> <div style="display:flex; align-items:center; flex:1;"> ${isRearranging ? `<span class="drag-handle-group">☰</span>` : ''} <input type="text" value="${group.label}" class="group-label-input" style="margin-left: 10px;" data-gidx="${gIdx}" ${isRearranging ? 'disabled' : ''} placeholder="Group Title"> </div> ${!isRearranging ? `<span class="tag-del delete-group" data-gidx="${gIdx}">✖</span>` : ''} </div> <div style="display:flex; flex-wrap:wrap; min-height: 20px;" class="tags-sortable-container" data-gidx="${gIdx}"> ${group.queries.map((q, qIdx) => ` <span class="tag-chip sortable-tag-item" data-qidx="${qIdx}"> ${isRearranging ? `<span class="drag-handle-tag">⋮</span>` : ''} ${q} ${!isRearranging ? `<span class="tag-del delete-tag" data-gidx="${gIdx}" data-qidx="${qIdx}">×</span>` : ''} </span> `).join('')} </div> ${!isRearranging ? ` <div style="display:flex; gap:5px; margin-top:10px;"> <input type="text" placeholder="Add tag..." class="new-tag-input" data-gidx="${gIdx}" style="flex:1; padding:4px;"> <button class="btn btn-save add-tag-btn" data-gidx="${gIdx}" style="padding:4px 12px;">+</button> <button class="btn btn-current add-current-query-btn" data-gidx="${gIdx}" title="Add current search bar content" style="padding:4px 8px;">+ Current</button> </div> ` : ''} </div> `, NEW_PROFILE_WARNING: ` <div class="warning-box"> <strong>⚠️ New Website Detected!</strong><br> This website doesn't have a profile yet. Please click <b>"Save & Apply"</b> to save settings for this site. </div> `, SAVED_SEARCHES_HEADER: (isRearranging) => ` <div class="section-title" style="display:flex; justify-content:space-between; align-items:center;"> <span>Saved Searches ${isRearranging ? '(Rearrange Mode)' : ''}</span> <button class="btn btn-rearrange" id="btn-rearrange">⇅ Rearrange</button> </div> <div id="cards-container" class="card"></div> ` } }; // ========================================== // [Module 3] Storage // ========================================== const Storage = { STORAGE_KEY: "SaveYourSearches_Data", load() { const rawData = GM_getValue(this.STORAGE_KEY, null); if (!rawData) { this.save(Helper.Config.DEFAULT_DATA); return Helper.Config.DEFAULT_DATA; } try { const data = JSON.parse(rawData); Object.keys(Helper.Config.DEFAULT_DATA.profiles).forEach(key => { if (!data.profiles[key]) { data.profiles[key] = Helper.Config.DEFAULT_DATA.profiles[key]; } else { const defaultProf = Helper.Config.DEFAULT_DATA.profiles[key]; ["cheatsheetUrl", "showCheatsheet", "wikiUrl", "showWiki", "defaultOpenQueryBox", "delimiter", "splitRegex", "showSettingsBtn"].forEach(field => { if (data.profiles[key][field] === undefined) data.profiles[key][field] = defaultProf[field]; }); } }); return data; } catch (e) { return Helper.Config.DEFAULT_DATA; } }, save(data) { data.settings.lastUpdate = new Date().toISOString(); GM_setValue(this.STORAGE_KEY, JSON.stringify(data)); EventBus.emit('storage:updated', data); } }; // ========================================== // [Module 4] UIEngine // ========================================== const UIEngine = { container: null, shadowRoot: null, panel: null, tempData: null, editingHost: null, isRearranging: false, sortableInstances: [], init() { this.container = document.createElement('div'); this.container.id = 'sys-ui-root'; document.body.appendChild(this.container); this.shadowRoot = this.container.attachShadow({ mode: 'open' }); this.shadowRoot.innerHTML = Helper.Templates.PANEL_SHELL(""); this.panel = this.shadowRoot.getElementById('main-panel'); this.setupPanelDragging(); this.bindBaseButtons(); // Binds static footer buttons EventBus.on('storage:updated', () => this.refreshSettings()); }, isNewProfile: false, toggle() { if (getComputedStyle(this.panel).display === 'none') { this.tempData = JSON.parse(JSON.stringify(Storage.load())); this.editingHost = window.location.host; this.isRearranging = false; if (!this.tempData.profiles[this.editingHost]) { this.tempData.profiles[this.editingHost] = { name: this.editingHost, selectors: { searchBar: "", insertPoint: "" }, cheatsheetUrl: "", showCheatsheet: false, wikiUrl: "", showWiki: false, defaultOpenQueryBox: false, delimiter: " ", splitRegex: "\\s+", groups: [] }; } this.refreshSettings(); this.panel.style.display = 'flex'; } else { this.panel.style.display = 'none'; } }, refreshSettings() { this.sortableInstances.forEach(i => i.destroy()); this.sortableInstances = []; const storedData = Storage.load(); this.isNewProfile = !storedData.profiles[this.editingHost]; const content = this.shadowRoot.getElementById('settings-content'); const profile = this.tempData.profiles[this.editingHost]; let html = this.isNewProfile ? Helper.Templates.NEW_PROFILE_WARNING : ""; html += Helper.Templates.PROFILE_SECTION(this.tempData.profiles, this.editingHost); html += Helper.Templates.SITE_SELECTORS_SECTION(profile, this.isRearranging); html += Helper.Templates.SITE_RESOURCES_SECTION(profile, this.isRearranging); html += Helper.Templates.SAVED_SEARCHES_HEADER(this.isRearranging); if (!this.isRearranging) html += `<button class="btn btn-add" id="btn-add-group">+ Add New Group</button>`; content.innerHTML = html; const container = this.shadowRoot.getElementById('cards-container'); profile.groups.forEach((group, gIdx) => { const cardDiv = document.createElement('div'); cardDiv.innerHTML = Helper.Templates.TAG_GROUP_HTML(group, gIdx, this.isRearranging); container.appendChild(cardDiv.firstElementChild); }); this.bindDynamicEvents(); if (this.isRearranging) this.setupSortable(); }, setupSortable() { const profile = this.tempData.profiles[this.editingHost]; const groupContainer = this.shadowRoot.getElementById('cards-container'); this.sortableInstances.push(new Sortable(groupContainer, { animation: 180, handle: '.drag-handle-group', ghostClass: 'sortable-ghost', onEnd: (evt) => { const movedGroup = profile.groups.splice(evt.oldIndex, 1)[0]; profile.groups.splice(evt.newIndex, 0, movedGroup); } })); this.shadowRoot.querySelectorAll('.tags-sortable-container').forEach(el => { this.sortableInstances.push(new Sortable(el, { animation: 180, handle: '.drag-handle-tag', ghostClass: 'sortable-ghost', group: 'shared-tags', // [Changed] Common group name allows cross-list dragging onEnd: (evt) => { // [Changed] Identify source and destination group indices const fromGidx = evt.from.dataset.gidx; const toGidx = evt.to.dataset.gidx; // [Changed] Target the specific arrays const fromQueries = profile.groups[fromGidx].queries; const toQueries = profile.groups[toGidx].queries; // [Changed] Move the item from the old array to the new array const movedQuery = fromQueries.splice(evt.oldIndex, 1)[0]; toQueries.splice(evt.newIndex, 0, movedQuery); // [Changed] Refresh UI if moved to a new group to update HTML indices if (fromGidx !== toGidx) { this.refreshSettings(); } } })); }); }, showActionModal(titleText, onCurrent, onAll) { const modal = this.shadowRoot.getElementById('action-modal'); const title = this.shadowRoot.getElementById('modal-title'); const btnCurrent = this.shadowRoot.getElementById('modal-btn-current'); const btnAll = this.shadowRoot.getElementById('modal-btn-all'); const btnCancel = this.shadowRoot.getElementById('modal-btn-cancel'); title.innerText = titleText; modal.style.display = 'flex'; const closeModal = () => { modal.style.display = 'none'; btnCurrent.onclick = null; btnAll.onclick = null; btnCancel.onclick = null; }; btnCurrent.onclick = () => { onCurrent(); closeModal(); }; btnAll.onclick = () => { onAll(); closeModal(); }; btnCancel.onclick = closeModal; }, handleExport() { this.showActionModal( "Select Export Scope", () => { const dataToExport = this.tempData.profiles[this.editingHost]; this.executeExport(dataToExport, 'SINGLE'); }, () => { const dataToExport = this.tempData; this.executeExport(dataToExport, 'ALL'); } ); }, executeExport(dataToExport, mode) { const blob = new Blob([JSON.stringify(dataToExport, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const fileNamePrefix = mode === 'SINGLE' ? `Save_Your_Searches_backup_${this.editingHost}` : `Save_Your_Searches_backup_ALL`; a.download = `${fileNamePrefix}_${new Date().getTime()}.json`; a.click(); URL.revokeObjectURL(url); window.alert("Export complete!"); }, handleImport(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const imported = JSON.parse(e.target.result); this.showActionModal( "Import Data (Will overwrite existing)", () => { const profileToImport = imported.profiles ? imported.profiles[this.editingHost] : imported; if (!profileToImport || !profileToImport.groups) throw new Error("Invalid profile format"); this.tempData.profiles[this.editingHost] = profileToImport; window.alert("Import success! Click 'Save & Apply' to take effect."); this.refreshSettings(); }, () => { if (!imported.profiles) throw new Error("Invalid format for full backup"); this.tempData = imported; window.alert("Import success! Click 'Save & Apply' to take effect."); this.refreshSettings(); } ); } catch (err) { window.alert("Import Failed: " + err.message); } }; reader.readAsText(file); event.target.value = ''; }, bindDynamicEvents() { const profile = this.tempData.profiles[this.editingHost]; // [Modified] Rearrange button is now generated inside settings-content dynamically const btnRearrange = this.shadowRoot.getElementById('btn-rearrange'); if (btnRearrange) { btnRearrange.classList.toggle('active', this.isRearranging); btnRearrange.onclick = () => { this.isRearranging = !this.isRearranging; this.refreshSettings(); }; } if (this.isRearranging) return; this.shadowRoot.getElementById('profile-selector').onchange = (e) => { this.editingHost = e.target.value; this.refreshSettings(); }; this.shadowRoot.getElementById('input-sel-search').oninput = (e) => { profile.selectors.searchBar = e.target.value; }; this.shadowRoot.getElementById('input-sel-insert').oninput = (e) => { profile.selectors.insertPoint = e.target.value; }; this.shadowRoot.getElementById('input-delimiter').oninput = (e) => { profile.delimiter = e.target.value; }; this.shadowRoot.getElementById('input-split-regex').oninput = (e) => { profile.splitRegex = e.target.value; }; this.shadowRoot.getElementById('input-cheatsheet-url').oninput = (e) => { profile.cheatsheetUrl = e.target.value; }; this.shadowRoot.getElementById('check-show-cheatsheet').onchange = (e) => { profile.showCheatsheet = e.target.checked; }; this.shadowRoot.getElementById('input-wiki-url').oninput = (e) => { profile.wikiUrl = e.target.value; }; this.shadowRoot.getElementById('check-show-wiki').onchange = (e) => { profile.showWiki = e.target.checked; }; this.shadowRoot.getElementById('check-show-settings').onchange = (e) => { profile.showSettingsBtn = e.target.checked; }; // [NEW] this.shadowRoot.getElementById('check-default-open').onchange = (e) => { profile.defaultOpenQueryBox = e.target.checked; }; this.shadowRoot.querySelectorAll('.delete-tag').forEach(btn => { btn.onclick = (e) => { const { gidx, qidx } = e.target.dataset; profile.groups[gidx].queries.splice(qidx, 1); this.refreshSettings(); }; }); this.shadowRoot.querySelectorAll('.add-tag-btn').forEach(btn => { btn.onclick = (e) => { const gidx = e.target.dataset.gidx; const val = this.shadowRoot.querySelectorAll('.new-tag-input')[gidx].value.trim(); if (val) { profile.groups[gidx].queries.push(val); this.refreshSettings(); } }; }); this.shadowRoot.querySelectorAll('.add-current-query-btn').forEach(btn => { btn.onclick = (e) => { const gidx = e.target.dataset.gidx; const bar = document.querySelector(profile.selectors.searchBar); if (bar && bar.value.trim()) { const fullQuery = bar.value.trim(); // Grab the entire search bar content if (!profile.groups[gidx].queries.includes(fullQuery)) { profile.groups[gidx].queries.push(fullQuery); this.refreshSettings(); } } }; }); this.shadowRoot.querySelectorAll('.group-label-input').forEach(input => { input.onchange = (e) => { profile.groups[e.target.dataset.gidx].label = e.target.value; }; }); this.shadowRoot.querySelectorAll('.delete-group').forEach(btn => { btn.onclick = (e) => { if(confirm("Delete this group?")) { profile.groups.splice(e.target.dataset.gidx, 1); this.refreshSettings(); } }; }); this.shadowRoot.getElementById('btn-add-group').onclick = () => { profile.groups.push({ id: "g_"+Date.now(), label: "New Group", queries: [] }); this.refreshSettings(); }; }, // [Modified] Binds static elements located in the base PANEL_SHELL template bindBaseButtons() { // Data row buttons this.shadowRoot.getElementById('btn-export-trigger').onclick = () => this.handleExport(); this.shadowRoot.getElementById('btn-import-trigger').onclick = () => this.shadowRoot.getElementById('sys-import-file').click(); this.shadowRoot.getElementById('sys-import-file').onchange = (e) => this.handleImport(e); // Action row buttons this.shadowRoot.getElementById('btn-cancel').onclick = () => this.toggle(); this.shadowRoot.getElementById('btn-save').onclick = () => { Storage.save(this.tempData); this.toggle(); }; this.shadowRoot.getElementById('btn-reset').onclick = () => { if(confirm("Reset ALL data?")) { Storage.save(Helper.Config.DEFAULT_DATA); this.toggle(); } }; }, setupPanelDragging() { const handle = this.shadowRoot.getElementById('drag-handle'); let startX, startY, initialLeft, initialTop; const onMouseMove = (e) => { this.panel.style.left = `${initialLeft + (e.clientX - startX)}px`; this.panel.style.top = `${initialTop + (e.clientY - startY)}px`; }; const onMouseUp = () => { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; handle.onmousedown = (e) => { if (e.button !== 0) return; startX = e.clientX; startY = e.clientY; initialLeft = this.panel.offsetLeft; initialTop = this.panel.offsetTop; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }; } }; // ========================================== // [Module 5] SiteInjector // ========================================== const SiteInjector = { async init() { const host = window.location.host; let data = Storage.load(); if (data.profiles[host]) this.injectUI(data.profiles[host]); EventBus.on('storage:updated', (newData) => { if (newData.profiles[host]) this.injectUI(newData.profiles[host]); }); }, async injectUI(profile) { const oldUI = document.getElementById('sys-wrapper'); if (oldUI) oldUI.remove(); if (!profile.selectors.insertPoint) return; const target = await this.waitForElement(profile.selectors.insertPoint); if (!target) return; const wrapper = document.createElement('div'); wrapper.id = 'sys-wrapper'; wrapper.style = Helper.Styles.INJECTOR_WRAPPER; const triggerBtn = document.createElement('button'); triggerBtn.innerText = "🔍 Saved Searches"; triggerBtn.style = Helper.Styles.TRIGGER_BTN; // Settings Button Logic let settingsBtn = null; if (profile.showSettingsBtn !== false) { // Defaults to true if undefined settingsBtn = document.createElement('button'); settingsBtn.innerText = "⚙️"; settingsBtn.style = Helper.Styles.SETTINGS_BTN; settingsBtn.onclick = (e) => { e.preventDefault(); UIEngine.toggle(); // Opens the settings panel }; } let cheatsheetBtn = null; if (profile.showCheatsheet && profile.cheatsheetUrl) { cheatsheetBtn = document.createElement('a'); cheatsheetBtn.innerText = "📄 Cheatsheet"; cheatsheetBtn.href = profile.cheatsheetUrl; cheatsheetBtn.target = "_blank"; cheatsheetBtn.style = Helper.Styles.CHEATSHEET_BTN; } let wikiBtn = null; if (profile.showWiki && profile.wikiUrl) { wikiBtn = document.createElement('a'); wikiBtn.innerText = "🌐 Wiki"; wikiBtn.href = profile.wikiUrl; wikiBtn.target = "_blank"; wikiBtn.style = Helper.Styles.WIKI_BTN; } const queryBox = this.createBox(profile); const shouldBeOpen = profile.defaultOpenQueryBox === true; queryBox.style.display = shouldBeOpen ? "block" : "none"; triggerBtn.style.background = shouldBeOpen ? "#2196f3" : "#6c757d"; triggerBtn.onclick = (e) => { e.preventDefault(); const isHidden = queryBox.style.display === "none"; queryBox.style.display = isHidden ? "block" : "none"; triggerBtn.style.background = isHidden ? "#2196f3" : "#6c757d"; }; wrapper.appendChild(triggerBtn); if (settingsBtn) wrapper.appendChild(settingsBtn); if (cheatsheetBtn) wrapper.appendChild(cheatsheetBtn); if (wikiBtn) wrapper.appendChild(wikiBtn); wrapper.appendChild(queryBox); target.parentNode.insertBefore(wrapper, target.nextSibling); const searchBar = document.querySelector(profile.selectors.searchBar); if (searchBar) { searchBar.addEventListener('input', () => this.updateHighlights(profile)); searchBar.addEventListener('change', () => this.updateHighlights(profile)); this.updateHighlights(profile); } }, createBox(profile) { const box = document.createElement('div'); box.id = 'sys-query-box'; box.style = Helper.Styles.QUERY_BOX; profile.groups.forEach((group, index) => { if (group.queries.length === 0) return; const card = document.createElement('div'); card.style = Helper.Styles.GROUP_CARD(index === profile.groups.length - 1); const title = document.createElement('div'); title.innerText = group.label; title.style = Helper.Styles.GROUP_TITLE; const tagsCont = document.createElement('div'); tagsCont.style = "display: flex; flex-wrap: wrap; gap: 6px;"; group.queries.forEach(query => { const tag = document.createElement('span'); tag.innerText = query; tag.style = Helper.Styles.TAG_ITEM; tag.setAttribute('data-sys-query', query); tag.onclick = () => this.toggleQuery(profile, query); tagsCont.appendChild(tag); }); card.appendChild(title); card.appendChild(tagsCont); box.appendChild(card); }); return box; }, _escapeRegex(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }, // 替換原本的 toggleQuery 函式 toggleQuery(profile, query) { const bar = document.querySelector(profile.selectors.searchBar); if (!bar) return; const delimiterStr = profile.delimiter !== undefined ? profile.delimiter : " "; const delimiterRegexStr = profile.splitRegex || "\\s+"; const escapedQuery = this._escapeRegex(query); // 建立邊界比對 Regex:尋找 (開頭或分隔符) + 標籤 + (結尾或分隔符) // 使用 $1 捕捉前面的邊界,避免刪除時把前面的分隔符也吃掉 const matchRegex = new RegExp(`(^|${delimiterRegexStr})${escapedQuery}(?=${delimiterRegexStr}|$)`, 'g'); if (matchRegex.test(bar.value)) { // 如果標籤存在,將其移除。 // 將標籤替換為它前面的邊界符號 ($1),接著清理多餘的連續分隔符號 bar.value = bar.value.replace(matchRegex, '$1') .replace(new RegExp(`(${delimiterRegexStr})+`, 'g'), delimiterStr) .trim(); if (bar.value.length > 0) bar.value += delimiterStr; // 保持結尾有空格的習慣 } else { // 如果標籤不存在,將其加到最後 let currentVal = bar.value.trim(); bar.value = (currentVal ? currentVal + delimiterStr : "") + query + delimiterStr; } bar.dispatchEvent(new Event('input', { bubbles: true })); }, // 替換原本的 updateHighlights 函式 updateHighlights(profile) { const bar = document.querySelector(profile.selectors.searchBar); if (!bar) return; const box = document.getElementById('sys-query-box'); if (!box) return; const delimiterRegexStr = profile.splitRegex || "\\s+"; box.querySelectorAll('span[data-sys-query]').forEach(tagEl => { const query = tagEl.getAttribute('data-sys-query'); const escapedQuery = this._escapeRegex(query); // 使用相同的邊界比對邏輯測試該標籤是否存在於搜尋列中 const matchRegex = new RegExp(`(^|${delimiterRegexStr})${escapedQuery}(?=${delimiterRegexStr}|$)`); const isMatched = matchRegex.test(bar.value); tagEl.style.cssText = isMatched ? Helper.Styles.TAG_ITEM_ACTIVE : Helper.Styles.TAG_ITEM; }); }, waitForElement(selector) { return new Promise(resolve => { if (document.querySelector(selector)) return resolve(document.querySelector(selector)); const obs = new MutationObserver(() => { if (document.querySelector(selector)) { obs.disconnect(); resolve(document.querySelector(selector)); } }); obs.observe(document.body, { childList: true, subtree: true }); }); } }; // ========================================== // Boot Initialization // ========================================== UIEngine.init(); SiteInjector.init(); GM_registerMenuCommand("Open Settings Panel", () => UIEngine.toggle()); const targetWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; targetWindow.toggleQM = () => UIEngine.toggle(); })();