// ==UserScript==
// @name 指定输入框自动补全
// @namespace http://tampermonkey.net/
// @version 2.0
// @description 实时记录指定文本框输入内容,点击指定按钮保存,下次输入时可自动匹配补全,支持右键补全及上下键切换匹配内容;匹配到内容时文本框内右上角会出现×号,点击可删除该匹配项;
// @author damu
// @match *://*/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_notification
// @license MIT
// @require https://code.jquery.com/jquery-3.6.0.min.js
// ==/UserScript==
/* global $ */
(function () {
'use strict';
// -------------------------------
// 常量定义:存储 key 和默认选择器
// -------------------------------
const CONTENT_SELECTOR_KEY = 'content_selector';
const BUTTON_SELECTOR_KEY = 'button_selector';
const RECORDED_CONTENT_KEY = 'recorded_content';
const HISTORY_KEY = 'history_list';
const DEFAULT_CONTENT_SELECTOR = 'body > div.v-transfer-dom > div.ivu-modal-wrap.ivu-modal-no-mask > div.ivu-modal > div.ivu-modal-content.ivu-modal-content-no-mask > div.ivu-modal-body > div.modal-content > form.ivu-form.ivu-form-label-top > div.ivu-form-item.ivu-form-item-required > div.ivu-form-item-content > div.customLayoutInput > div.customInput.horizontalLtr[contenteditable="false"]';
const DEFAULT_BUTTON_SELECTOR = 'body > div.v-transfer-dom > div.ivu-modal-wrap.ivu-modal-no-mask > div.ivu-modal > div.ivu-modal-content.ivu-modal-content-no-mask > div.ivu-modal-footer > button.ivu-btn.ivu-btn-default[type="button"]';
// -------------------------------
// 全局变量
// -------------------------------
let contentSelector = GM_getValue(CONTENT_SELECTOR_KEY, DEFAULT_CONTENT_SELECTOR); // 当前输入框选择器
let buttonSelector = GM_getValue(BUTTON_SELECTOR_KEY, DEFAULT_BUTTON_SELECTOR); // 当前按钮选择器
let recordedContent = GM_getValue(RECORDED_CONTENT_KEY, ''); // 当前输入内容
let historyList = GM_getValue(HISTORY_KEY, []); // 历史记录列表
let isSelectingContent = false; // 是否正在选择内容区域
let isSelectingButton = false; // 是否正在选择按钮
let highlightElement = null; // 鼠标悬停高亮元素
let ghostEl = null; // 自动补全提示元素
let activeInput = null; // 当前激活输入框
let matches = [], matchIndex = -1; // 匹配内容数组及当前索引
// -------------------------------
// 注册(不可用)菜单命令
// -------------------------------
GM_registerMenuCommand('点击选择内容区域', startContentSelection);
GM_registerMenuCommand('点击选择按钮', startButtonSelection);
GM_registerMenuCommand('显示当前配置', showCurrentConfig);
GM_registerMenuCommand('清除记录的内容', clearRecordedContent);
// -------------------------------
// 内容区域选择功能
// -------------------------------
function startContentSelection() {
if (isSelectingContent || isSelectingButton) return;
isSelectingContent = true;
notify('请点击页面上的内容区域来选择它');
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('click', handleContentSelection, true);
setTimeout(cancelContentSelection, 10000); // 10秒超时取消选择
}
function handleMouseMove(e) {
if (!isSelectingContent && !isSelectingButton) return;
const element = document.elementFromPoint(e.clientX, e.clientY);
if (highlightElement === element) return;
if (highlightElement) highlightElement.style.outline = '';
if (element) {
element.style.outline = '2px solid red';
highlightElement = element;
}
}
function handleContentSelection(e) {
e.preventDefault();
e.stopPropagation();
const selector = generateSelector(e.target);
if (selector) {
contentSelector = selector;
GM_setValue(CONTENT_SELECTOR_KEY, contentSelector);
removeContentEventListeners();
setupContentEventListeners();
notify('内容区域选择成功');
}
cancelContentSelection();
}
function cancelContentSelection() {
if (!isSelectingContent) return;
isSelectingContent = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('click', handleContentSelection, true);
if (highlightElement) highlightElement.style.outline = '';
highlightElement = null;
}
// -------------------------------
// 按钮选择功能
// -------------------------------
function startButtonSelection() {
if (isSelectingContent || isSelectingButton) return;
isSelectingButton = true;
notify('请点击页面上的按钮来选择它');
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('click', handleButtonSelection, true);
setTimeout(cancelButtonSelection, 10000);
}
function handleButtonSelection(e) {
e.preventDefault();
e.stopPropagation();
const selector = generateSelector(e.target);
if (selector) {
buttonSelector = selector;
GM_setValue(BUTTON_SELECTOR_KEY, buttonSelector);
removeButtonEventListeners();
setupButtonEventListeners();
notify('按钮选择成功');
}
cancelButtonSelection();
}
function cancelButtonSelection() {
if (!isSelectingButton) return;
isSelectingButton = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('click', handleButtonSelection, true);
if (highlightElement) highlightElement.style.outline = '';
highlightElement = null;
}
// -------------------------------
// 输入框事件监听
// -------------------------------
function setupContentEventListeners() {
// 输入事件:记录当前内容并显示提示(在新输入时重置为最新索引 0)
$(document).on('input', contentSelector, function (e) {
recordedContent = getElementContent(e.target);
GM_setValue(RECORDED_CONTENT_KEY, recordedContent);
activeInput = e.target;
matchIndex = 0; // 关键:新输入时默认显示最新的一条
showGhost(activeInput, recordedContent);
});
// 键盘事件:↑ 向更旧(index++),↓ 向更新(index--),不循环;→ 确认补全
$(document).on('keydown', contentSelector, function (e) {
if (!matches.length) return;
const key = e.key;
if (key === 'ArrowDown') {
// 已经是最新(0)时不变;否则向更新方向移动(index--)
if (matchIndex > 0) {
matchIndex = matchIndex - 1;
showGhost(this, this.value || this.innerText);
}
e.preventDefault();
} else if (key === 'ArrowUp') {
// 向更旧的记录走(index++),到头就停(不循环)
if (matchIndex < matches.length - 1) {
matchIndex = matchIndex + 1;
showGhost(this, this.value || this.innerText);
}
e.preventDefault();
} else if (key === 'ArrowRight' && matchIndex >= 0 && matches[matchIndex]) {
applyMatch(this, matches[matchIndex]);
e.preventDefault();
}
});
// 失去焦点时隐藏提示
$(document).on('blur', contentSelector, hideGhost);
}
function removeContentEventListeners() {
$(document).off('input', contentSelector)
.off('keydown', contentSelector)
.off('blur', contentSelector);
}
// -------------------------------
// 按钮点击事件监听
// -------------------------------
function setupButtonEventListeners() {
$(document).on('click', buttonSelector, function () {
console.log("文本框内容:", recordedContent);
if (recordedContent && recordedContent.length >= 5) {
const existingIndex = historyList.indexOf(recordedContent);
if (existingIndex !== -1) {
// 已存在,把它移到数组末尾
historyList.splice(existingIndex, 1);
historyList.push(recordedContent);
console.log("已存在,移动到末尾=>", recordedContent);
} else {
// 不存在,直接添加
historyList.push(recordedContent);
console.log("不存在,添加=>", recordedContent);
}
// 限制最大记录数为 1000
if (historyList.length > 1000) {
historyList = historyList.slice(historyList.length - 1000);
}
GM_setValue(HISTORY_KEY, historyList);
}
});
}
function removeButtonEventListeners() {
$(document).off('click', buttonSelector);
}
// -------------------------------
// 生成元素选择器
// 优先使用 id,如果没有 id,则使用 class,并沿父元素层级拼接
// -------------------------------
function generateSelector(element) {
if (!element || !element.tagName) return null;
// 如果有 ID,直接返回唯一选择器
if (element.id) return `#${element.id}`;
// 初始化数组存储每一层的选择器
let path = [];
let el = element;
while (el && el.nodeType === 1 && el.tagName.toLowerCase() !== 'html') {
let selector = el.tagName.toLowerCase();
// 使用 class 名,如果有多个,只取前两个避免过长
if (el.className && typeof el.className === 'string') {
const classes = el.className.trim().split(/\s+/).slice(0, 2);
if (classes.length > 0) {
selector += '.' + classes.join('.');
}
}
// 如果有关键属性可以进一步增强选择器唯一性
const attrs = ['name', 'type', 'data-id', 'role', 'contenteditable'];
for (const attr of attrs) {
if (el.hasAttribute(attr)) {
selector += `[${attr}="${el.getAttribute(attr)}"]`;
}
}
path.unshift(selector); // 插入数组开头
el = el.parentElement;
}
// 用 > 拼接每一层,形成完整路径
return path.join(' > ');
}
// -------------------------------
// 获取元素内容
// -------------------------------
function getElementContent(element) {
const $el = $(element);
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') return $el.val() || '';
if (element.hasAttribute('contenteditable')) return $el.text() || $el.html() || '';
return $el.text() || $el.val() || $el.html() || '';
}
// -------------------------------
// 通知
// -------------------------------
function notify(msg) {
if (typeof GM_notification !== 'undefined') {
GM_notification({ text: msg, timeout: 1500, title: '内容记录工具' });
}
}
// -------------------------------
// 显示当前配置
// -------------------------------
function showCurrentConfig() {
alert('内容区域选择器:' + contentSelector +
'\n按钮选择器:' + buttonSelector +
'\n已保存条目数:' + historyList.length);
console.log('内容区域选择器:' + contentSelector +
'\n按钮选择器:' + buttonSelector +
'\n已保存条目数:' + historyList.length);
}
// -------------------------------
// 清除记录
// -------------------------------
function clearRecordedContent() {
recordedContent = '';
historyList = [];
GM_setValue(RECORDED_CONTENT_KEY, '');
GM_setValue(HISTORY_KEY, []);
notify('已清除记录');
}
// -------------------------------
// 自动补全提示功能
// -------------------------------
function showGhost(el, text) {
matches = historyList.slice().reverse().filter(item => item.includes(text) && item !== text);
if (!matches.length) {
matchIndex = -1; // 没有匹配时重置索引
hideGhost();
return;
}
if (matchIndex < 0 || matchIndex >= matches.length) matchIndex = 0;
const full = matches[matchIndex];
if (!full) {
hideGhost();
return;
}
const idx = full.indexOf(text);
if (idx === -1) {
hideGhost();
return;
}
const prefix = full.slice(0, idx);
const middle = text;
const suffix = full.slice(idx + text.length);
hideGhost();
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
// canvas 计算 prefix 宽度
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.font = style.font;
const prefixWidth = ctx.measureText(prefix).width;
const ghost = document.createElement('div');
ghost.innerHTML =
`<span style="color:#aaa;white-space:pre;">${prefix}</span>` +
`<span style="color:transparent;white-space:pre;">${middle}</span>` +
`<span style="color:#aaa;white-space:pre; position: relative;">${suffix}</span>`;
Object.assign(ghost.style, {
position: 'absolute',
top: rect.top + window.scrollY + 'px',
left: (rect.left + window.scrollX - prefixWidth) + 'px',
width: rect.width + prefixWidth + 'px',
height: rect.height + 'px',
font: style.font,
lineHeight: style.lineHeight,
padding: style.padding,
margin: style.margin,
border: 'none',
background: 'transparent',
pointerEvents: 'auto',
whiteSpace: 'pre',
overflow: 'hidden',
zIndex: 9999
});
document.body.appendChild(ghost);
ghostEl = ghost;
// === 新增:单独创建叉号按钮 ===
const closeBtn = document.createElement('button');
closeBtn.className = 'ghost-close-btn';
closeBtn.type = 'button';
closeBtn.textContent = '✖';
Object.assign(closeBtn.style, {
position: 'absolute',
top: '0px',
right: '0px',
cursor: 'pointer',
color: '#aaa',
fontWeight: 'bold',
fontSize: '0.9em',
border: 'none',
background: 'transparent',
padding: '0 4px',
lineHeight: '1',
pointerEvents: 'auto',
zIndex: 10000
});
ghost.appendChild(closeBtn);
// 事件:mousedown 保证在 blur 前触发
closeBtn.addEventListener('mousedown', function (e) {
e.stopPropagation();
e.preventDefault();
const toDelete = matches[matchIndex];
if (toDelete) {
historyList = historyList.filter(item => item !== toDelete);
GM_setValue(HISTORY_KEY, historyList);
matches.splice(matchIndex, 1);
if (matches.length === 0) {
hideGhost();
} else {
if (matchIndex >= matches.length) matchIndex = matches.length - 1;
showGhost(activeInput, getElementContent(activeInput));
}
console.log("已删除:", toDelete);
}
});
}
function hideGhost() { if (ghostEl) { ghostEl.remove(); ghostEl = null; } }
function applyMatch(el, text) {
if (!el) return;
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') el.value = text;
else el.innerText = text;
['input', 'change'].forEach(e => el.dispatchEvent(new Event(e, { bubbles: true })));
recordedContent = text;
GM_setValue(RECORDED_CONTENT_KEY, text);
hideGhost();
}
// -------------------------------
// 初始化
// -------------------------------
function init() {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', attachListeners);
else attachListeners();
}
function attachListeners() {
setupContentEventListeners();
setupButtonEventListeners();
}
init();
})();