// ==UserScript==
// @name 磁吸笔记
// @namespace https://bbs.tampermonkey.net.cn/
// @version 0.4.0
// @description 右侧侧吸笔记卡片,可新建和切换,内容自动保存;初始收起,标签可删除。
// @author huanxue
// @match *://*/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant GM_deleteValue
// ==/UserScript==
(() => {
'use strict';
const STORAGE_META = 'notes_meta';
const NOTE_PREFIX = 'note_';
const getMeta = () => JSON.parse(GM_getValue(STORAGE_META, '[]'));
const saveMeta = (m) => GM_setValue(STORAGE_META, JSON.stringify(m));
const getContent = (id) => GM_getValue(NOTE_PREFIX + id, '');
const saveContent = (id, c) => GM_setValue(NOTE_PREFIX + id, c);
const delContent = (id) => GM_deleteValue(NOTE_PREFIX + id);
const debounce = (fn, d = 300) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), d); }; };
let sidebar, btnToggle, btnNew, noteTabs, noteEditor, headerBar;
let currentId = null;
let isDragging = false, startX = 0, startY = 0, startLeft = 0, startTop = 0;
let initialized = false;
const injectStyle = () => GM_addStyle(`
#noteSidebar {
position: fixed; top: 100px; right: 0;
width: 300px; background: #fafafa;
border-left: 1px solid #d0d0d0; box-shadow: -2px 0 6px rgba(0,0,0,.12);
font-family: system-ui, sans-serif; z-index: 2147483647;
transition: width .2s ease, opacity .2s ease;
user-select: none;
}
#noteSidebar.minimized { width: 40px; }
#noteSidebar header { display: flex; align-items: center; gap: 6px;
padding: 6px 8px; background: #f0f0f0; border-bottom: 1px solid #d0d0d0; cursor: move; }
#noteSidebar header button { padding: 4px 6px; font-size: 12px; cursor: pointer; }
#noteSidebar.minimized header span,
#noteSidebar.minimized #btnNew,
#noteSidebar.minimized #noteTabs,
#noteSidebar.minimized #noteEditor { display: none !important; }
#noteTabs { display: flex; flex-wrap: wrap; gap: 4px;
padding: 6px; max-height: 120px; overflow-y: auto; }
.note-tab { position: relative; cursor: pointer; background: #e0e0e0; border-radius: 4px;
padding: 4px 18px 4px 6px; font-size: 12px; }
.note-tab.active { background: #c0c0c0; font-weight: 600; }
.note-close { position: absolute; right: 4px; top: 1px; cursor: pointer; font-weight: 700; line-height: 12px; }
.note-close:hover { color: #d00; }
#noteEditor { width: 100%; height: 200px; border: none; resize: vertical;
padding: 8px; box-sizing: border-box; font-size: 13px; line-height: 1.4;
border-top: 1px solid #d0d0d0; background: #fff; outline: none; }
`);
const initSidebar = () => {
if (initialized) return;
initialized = true;
injectStyle();
sidebar = document.createElement('div');
sidebar.id = 'noteSidebar';
sidebar.className = 'minimized';
sidebar.innerHTML = `
`;
document.body.appendChild(sidebar);
btnToggle = sidebar.querySelector('#btnToggle');
btnNew = sidebar.querySelector('#btnNew');
noteTabs = sidebar.querySelector('#noteTabs');
noteEditor = sidebar.querySelector('#noteEditor');
headerBar = sidebar.querySelector('#noteHeader');
const onMouseDown = (e) => {
if (e.button !== 0 || e.target.tagName === 'BUTTON') return;
isDragging = true;
startX = e.clientX; startY = e.clientY;
const r = sidebar.getBoundingClientRect();
startLeft = r.left; startTop = r.top;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
const onMouseMove = (e) => {
if (!isDragging) return;
const dx = e.clientX - startX, dy = e.clientY - startY;
sidebar.style.left = startLeft + dx + 'px';
sidebar.style.top = Math.max(0, startTop + dy) + 'px';
sidebar.style.right = 'auto';
};
const onMouseUp = () => {
if (!isDragging) return;
isDragging = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
const r = sidebar.getBoundingClientRect();
const vw = window.innerWidth, margin = 20;
if (r.left <= margin) {
sidebar.style.left = '0px'; sidebar.style.right = 'auto';
ensureMinimized(true);
} else if (vw - r.right <= margin) {
sidebar.style.left = 'auto'; sidebar.style.right = '0px';
ensureMinimized(true);
}
};
headerBar.addEventListener('mousedown', onMouseDown);
btnToggle.addEventListener('click', () => ensureMinimized(!sidebar.classList.contains('minimized')));
btnNew.addEventListener('click', () => {
const name = (prompt('新建卡片名称?', 'Note ' + (getMeta().length + 1)) || '').trim();
if (!name) return;
const id = Date.now().toString();
const meta = getMeta(); meta.push({ id, name }); saveMeta(meta);
openNote(id);
});
noteEditor.addEventListener('input', debounce(() => {
if (currentId) saveContent(currentId, noteEditor.value);
}, 500));
let meta = getMeta();
if (!meta.length) {
const id = Date.now().toString();
meta = [{ id, name: 'Note 1' }];
saveMeta(meta);
}
openNote(meta[0].id);
sidebar.style.top = '100px';
sidebar.style.left = 'auto';
sidebar.style.right = '0px';
};
const ensureMinimized = (wantMinimize) => {
if (wantMinimize) {
sidebar.classList.add('minimized');
btnToggle.textContent = '☝️';
btnToggle.title = '展开';
} else {
sidebar.classList.remove('minimized');
btnToggle.textContent = '👐';
btnToggle.title = '收起';
}
};
const renderTabs = () => {
noteTabs.innerHTML = '';
for (const { id, name } of getMeta()) {
const tab = document.createElement('div');
tab.className = 'note-tab' + (id === currentId ? ' active' : '');
tab.innerHTML = `${name}-`;
tab.addEventListener('click', () => openNote(id));
tab.querySelector('.note-close').addEventListener('click', (e) => {
e.stopPropagation();
deleteNote(id);
});
noteTabs.appendChild(tab);
}
};
const openNote = (id) => {
currentId = id;
noteEditor.value = getContent(id);
renderTabs();
};
const deleteNote = (id) => {
let meta = getMeta().filter(v => v.id !== id);
saveMeta(meta); delContent(id);
if (currentId === id) {
if (meta.length) openNote(meta[0].id);
else {
const nid = Date.now().toString();
meta = [{ id: nid, name: 'Note 1' }];
saveMeta(meta); openNote(nid);
}
} else renderTabs();
};
const handleVisibility = () => {
if (document.visibilityState === 'visible') {
if (!initialized) initSidebar();
else sidebar.style.display = 'block';
} else if (initialized) {
sidebar.style.display = 'none';
}
};
document.addEventListener('visibilitychange', handleVisibility);
handleVisibility();
})();