// ==UserScript==
// @name IPE自动保存 (on THBWiki)
// @namespace https://greasyfork.org/users/551710
// @version 1.0
// @description 自动保存 InPageEdit 在每个页面的上一次编辑内容到本地,也支持保存默认编辑器的内容
// @author Gzz
// @match *://thwiki.cc/*
// @match *://touhou.review/*
// @icon https://static.thbwiki.cc/favicon.ico
// @license MIT
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// ==/UserScript==
(function() {
const style = document.createElement('style');
style.innerHTML = `
.ipe-autosave-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
overflow: auto;
z-index: 9961;
}
.ipe-autosave-content {
background: white;
border-radius: 5px;
width: fit-content;
margin: 40px auto;
padding: 30px 20px 20px;
position: relative;
outline: none;
}
.ipe-autosave-content button, .ipe-autosave-toolbar button {
font-size: 14px;
font-weight: bold;
color: #222;
border: 1px solid #c8ccd1;
border-radius: 2px;
padding: 0.2em 0.6em;
background-color: #f8f9fa;
}
.ipe-autosave-content button:hover, .ipe-autosave-toolbar button:hover {
background-color: #ffffff;
color: #454545;
}
.ipe-autosave-content button:active, .ipe-autosave-toolbar button:active {
border: 1px solid #36c;
}
.ipe-autosave-content button:focus, .ipe-autosave-toolbar button:focus {
box-shadow: inset 0 0 0 1px #36c;
}
.ipe-autosave-close {
position: absolute;
top: 5px;
right: 10px;
cursor: pointer;
font-weight: bold;
}
.ipe-autosave-close::after {
content: '×';
}
.ipe-autosave-body {
display: grid;
grid-template-columns: repeat(6, auto);
align-items: center;
gap: 5px;
}
.ipe-autosave-body > :nth-child(6n+1) {
justify-self: end;
}
.ipe-autosave-body > a {
width: fit-content;
max-width: 300px;
}
.ipe-autosave-body > .mark-btn {
font-size: 32px;
font-family: "Arial Unicode MS", "Segoe UI Symbol", sans-serif;
color: #666;
line-height: 0.5;
}
.ipe-autosave-body > .mark-btn::after {
content: "☆";
position: relative;
top: -2px;
transition: color 0.1s;
}
.ipe-autosave-body > .mark-btn:hover::after {
color: #FFB74D;
}
.ipe-autosave-body > .mark-btn.marked::after {
content: "★";
color: #FFB74D;
}
.ipe-autosave-footer > :first-child {
margin-top: 10px;
}
.ipe-autosave-toolbar {
display: flex;
align-items: center;
margin-top: 5px;
}
.ipe-autosave-toolbar > button {
margin-left: 40px;
}
.ipe-autosave-toolbar > button:last-of-type {
margin-left: auto;
}
.ipe-autosave-toolbar.right {
float: right;
}
.ipe-autosave-toolbar.right > * {
margin-right: 10px;
}
.ipe-autosave-toolbar.right > button {
font-size: 16px;
padding: 0.25em 0.8em;
}
`;
document.head.appendChild(style);
// 用于存放已经处理过的编辑器
const handledEditors = new WeakSet();
// 保存内容的天数
let expiry = GM_getValue('autosave_expiry', 7);
// 注册脚本菜单项
GM_registerMenuCommand('查看已保存的页面', () => {
if (document.getElementById('ipe-autosave')) return;
function generateList() {
body.innerHTML = footer.innerHTML = '';
const list = GM_getValue('autosave_list', []);
list.reverse().sort((a, b) => b.marked - a.marked);
let sizeTotal = 0;
list.forEach((item, index) => {
const title = item.title;
const text = GM_getValue(title);
const number = document.createElement('span');
number.textContent = (index + 1) + '.';
const link = document.createElement('a');
link.href = '/' + title;
link.textContent = title;
link.target = '_blank';
const mark = document.createElement('a');
mark.className = item.marked ? 'mark-btn marked' : 'mark-btn';
mark.title = '收藏后会禁止自动删除';
const time = document.createElement('span');
time.textContent = new Date(item.time).toLocaleString();
const size = document.createElement('span');
const length = byteLength(text);
size.textContent = `(${length.toLocaleString()} 字节)`;
sizeTotal += length;
const button = copyButton(title, text);
body.append(number, link, mark, time, size, button);
mark.addEventListener('click', () => {
const marked = mark.classList.toggle('marked');
const list = GM_getValue('autosave_list', []);
const obj = list.find(i => i.title === title);
if (obj) obj.marked = marked;
GM_setValue('autosave_list', list);
log(`已${marked ? '' : '取消'}收藏`, title);
});
});
const divSize = document.createElement('div');
divSize.textContent = `总大小: ${(sizeTotal / 1024).toFixed(2)} KB (${(sizeTotal / 1024 ** 2).toFixed(2)} MB)`;
const pageExpiry = document.createElement('span');
pageExpiry.textContent = '当前保存天数: ' + expiry;
const changeExpiry = document.createElement('button');
changeExpiry.textContent = '更改天数';
const clearAll = document.createElement('button');
clearAll.textContent = '清空全部';
const toolbar = document.createElement('div');
toolbar.className = 'ipe-autosave-toolbar';
toolbar.append(pageExpiry, changeExpiry, clearAll);
footer.append(divSize, toolbar);
changeExpiry.addEventListener('click', () => {
const input = prompt('将保存天数更改为', expiry);
if (/^\d+$/.test(input)) {
expiry = input;
GM_setValue('autosave_expiry', expiry);
log('保存天数已改为', expiry);
saveContent();
generateList();
} else if (input !== null) {
alert('请输入非负整数');
}
});
clearAll.addEventListener('click', () => {
const result = confirm('是否确认清空已保存的页面?');
if (result) {
const temp = expiry;
expiry = 0;
saveContent();
expiry = temp;
generateList();
log('已清空全部页面');
}
});
}
const overlay = document.createElement('div');
overlay.className = 'ipe-autosave-overlay';
overlay.id = 'ipe-autosave';
const content = document.createElement('div');
content.className = 'ipe-autosave-content';
content.tabIndex = '0';
const close = document.createElement('span');
close.className = 'ipe-autosave-close';
const body = document.createElement('div');
body.className = 'ipe-autosave-body';
const footer = document.createElement('div');
footer.className = 'ipe-autosave-footer';
content.append(close, body, footer);
overlay.append(content);
generateList();
document.body.append(overlay);
document.body.style.overflow = 'hidden';
content.focus();
log('已打开保存的页面列表');
function closeModal() {
overlay.remove();
document.body.style.overflow = '';
log('已关闭页面列表');
}
close.addEventListener('click', closeModal);
// 点击遮罩也可以关闭
overlay.addEventListener('click', (event) => {
if (event.target === overlay) closeModal();
});
});
// 函数: 给 log 加前缀
function log(...args) {
console.log('[IPE自动保存]', ...args);
}
// 函数: 计算 utf-8 编码下的字节数
function byteLength(str) {
return new TextEncoder().encode(str).length;
}
// 函数: 创建复制按钮
function copyButton(title, text) {
const button = document.createElement('button');
button.textContent = '复制内容';
let timer = null;
button.addEventListener('click', async () => {
await navigator.clipboard.writeText(text);
button.textContent = '复制成功';
log('复制成功:', title);
clearTimeout(timer);
timer = setTimeout(() => {
button.textContent = "复制内容";
}, 5000);
});
return button;
}
// 函数: 保存内容
function saveContent(title, text, span) {
// 已保存页面的列表
let list = GM_getValue('autosave_list', []);
const time = Date.now();
if (title) {
GM_setValue(title, text);
span.textContent = new Date(time).toLocaleString() + ' 已保存.';
log('已储存', title);
// 将页面加入列表
const marked = list.find(i => i.title === title)?.marked === true;
list = list.filter(i => i.title !== title);
list.push({ title: title, time: time, marked: marked });
}
// 清理超过保存天数的内容
const date = time - expiry * 86400 * 1000;
const kept = [], expired = [];
list.forEach(item => {
if (item.time > date || item.marked) {
kept.push(item);
} else {
expired.push(item);
}
});
expired.forEach(item => {
GM_deleteValue(item.title);
log('已清理:', item);
});
GM_setValue('autosave_list', kept);
}
// 函数: 启用自动保存
async function startAutoSave(editor, ipe = true) {
const title = editor.querySelector(ipe ? '.editPage' : '#firstHeadingTitle')?.innerText;
log('发现新编辑器:', title);
// 创建容器
const spanThis = document.createElement('span');
const toolbar = document.createElement('div');
toolbar.className = 'ipe-autosave-toolbar right';
toolbar.append(spanThis);
editor.querySelector(ipe ? '#ssi-buttons' : '.editButtons').prepend(toolbar);
// 读取上次保存的内容
const list = GM_getValue('autosave_list', []);
const obj = list.find(i => i.title === title);
if (obj) {
const time = new Date(obj.time).toLocaleString();
const oldText = GM_getValue(title);
// 创建按钮和文本
const button = copyButton(title, oldText);
button.title = '复制上次编辑时自动保存到本地的内容';
button.type = 'button';
const spanLast = document.createElement('span');
spanLast.textContent = '上次编辑: ' + time;
toolbar.append(spanLast, button);
}
let textarea, textInitial;
if (ipe) {
// 等待初始文本填充
textarea = editor.querySelector('textarea.editArea');
textInitial = await new Promise(resolve => {
const timer = setInterval(() => {
const text = textarea?.value;
if (text) {
clearInterval(timer);
resolve(text);
}
}, 50);
});
// 防止未保存时退出网页
window.addEventListener('beforeunload', (event) => {
const saving = document.querySelector('.in-page-edit.loadingbox');
if (textarea.value !== textInitial && document.body.contains(editor) && !saving) {
event.preventDefault();
event.returnValue = '';
}
});
} else {
textarea = editor.querySelector('#wpTextbox1');
textInitial = textarea.value;
}
// 每 5 秒检查一次
let textLast = null;
const timer = setInterval(() => {
// 如果编辑器关闭, 停止定时保存
if (!document.body.contains(editor)) {
clearInterval(timer);
handledEditors.delete(editor);
log('编辑器已关闭, 停止保存', title);
return;
}
// 与初始文本不同才会保存
const text = textarea.value;
if (text !== textInitial && text !== textLast) {
saveContent(title, text, spanThis);
textLast = text;
}
// 未保存退出时提示
textarea.dataset.modifiled = text !== textInitial;
}, 5000);
}
// 查找默认编辑器
if (document.getElementById('wpTextbox1')) {
startAutoSave(document.getElementById('content'), false);
}
// 监听 <body> 直接子元素变化
const observer = new MutationObserver(() => {
document.querySelectorAll('.in-page-edit.ipe-editor').forEach(editor => {
if (!handledEditors.has(editor)) {
handledEditors.add(editor);
startAutoSave(editor);
}
});
});
observer.observe(document.body, { childList: true });
})();