Manually append the current page title and URL to a local Markdown bookmark file.
// ==UserScript==
// @name Bookmarker
// @namespace http://tampermonkey.net/
// @version 1.0.0
// @description Manually append the current page title and URL to a local Markdown bookmark file.
// @author Xiang0731
// @license MIT
// @match *://*/*
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
if (window.top !== window.self) {
return;
}
const APP_ID = 'bookmarker';
const ROOT_ID = `${APP_ID}-root`;
const PANEL_ID = `${APP_ID}-panel`;
const REMARK_DIALOG_ID = `${APP_ID}-remark-dialog`;
const REMARK_HINT_ID = `${APP_ID}-remark-hint`;
const REMARK_INPUT_ID = `${APP_ID}-remark-input`;
const REMARK_SAVE_ID = `${APP_ID}-remark-save`;
const REMARK_CANCEL_ID = `${APP_ID}-remark-cancel`;
const TOGGLE_ID = `${APP_ID}-toggle`;
const TITLE_ID = `${APP_ID}-title`;
const FILE_ID = `${APP_ID}-file`;
const STATUS_ID = `${APP_ID}-status`;
const SAVE_ID = `${APP_ID}-save`;
const SAVE_WITH_REMARK_ID = `${APP_ID}-save-with-remark`;
const PICK_ID = `${APP_ID}-pick`;
const DB_NAME = 'bookmarker-db';
const STORE_NAME = 'handles';
const HANDLE_KEY = 'bookmark-markdown-file';
const BACKUP_DIRECTORY_NAME = 'bookmarker-backups';
const TABLE_HEADER_ROW = '| No | 标题 | 链接 | remark |';
const TABLE_DIVIDER_ROW = '| --- | --- | --- | --- |';
let isBusy = false;
let isPanelOpen = false;
let isRemarkDialogOpen = false;
let remarkDraftContext = null;
function normalizeText(value) {
return String(value || '')
.replace(/\s+/g, ' ')
.trim();
}
function getMetaContent(selector) {
const node = document.querySelector(selector);
return normalizeText(node?.content || '');
}
function getPageTitle() {
const documentTitle = normalizeText(document.title);
const metaTitle =
getMetaContent('meta[property="og:title"]') ||
getMetaContent('meta[name="twitter:title"]');
const headingTitle = normalizeText(document.querySelector('h1')?.textContent || '');
return documentTitle || metaTitle || headingTitle || location.hostname || 'Untitled';
}
function normalizeUrl(value) {
const raw = normalizeText(value);
if (!raw) {
return '';
}
try {
const url = new URL(raw, location.href);
url.hash = '';
return url.href;
} catch (error) {
return raw;
}
}
function escapeMarkdownTableText(value) {
return normalizeText(value)
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|');
}
function unescapeMarkdownTableText(value) {
const text = String(value || '');
let result = '';
for (let i = 0; i < text.length; i += 1) {
const current = text[i];
const next = text[i + 1];
if (current === '\\' && (next === '\\' || next === '|')) {
result += next;
i += 1;
continue;
}
result += current;
}
return result.trim();
}
function isEscapedCharacter(text, index) {
let slashCount = 0;
for (let i = index - 1; i >= 0 && text[i] === '\\'; i -= 1) {
slashCount += 1;
}
return slashCount % 2 === 1;
}
function parseMarkdownTableLine(line) {
const trimmed = String(line || '').trim();
if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) {
return null;
}
const cells = [];
let buffer = '';
for (let i = 1; i < trimmed.length - 1; i += 1) {
const char = trimmed[i];
if (char === '|' && !isEscapedCharacter(trimmed, i)) {
cells.push(buffer.trim());
buffer = '';
continue;
}
buffer += char;
}
cells.push(buffer.trim());
return cells;
}
function extractUrlFromTableCell(value) {
const text = normalizeText(value);
if (!text) {
return '';
}
const angleMatch = text.match(/^<(.+)>$/);
if (angleMatch?.[1]) {
return normalizeUrl(angleMatch[1]);
}
const markdownLinkMatch = text.match(/\((?:<)?([^()\s>]+)(?:>)?\)$/);
if (markdownLinkMatch?.[1]) {
return normalizeUrl(markdownLinkMatch[1]);
}
return normalizeUrl(text);
}
function countTrailingNewlines(text) {
const match = String(text || '').match(/\n*$/);
return match ? match[0].length : 0;
}
function buildBackupTimestamp(date) {
const current = date instanceof Date ? date : new Date();
const parts = [
current.getFullYear(),
String(current.getMonth() + 1).padStart(2, '0'),
String(current.getDate()).padStart(2, '0'),
'-',
String(current.getHours()).padStart(2, '0'),
String(current.getMinutes()).padStart(2, '0'),
String(current.getSeconds()).padStart(2, '0')
];
return parts.join('');
}
function sanitizeBackupBaseName(name) {
const normalized = normalizeText(name || 'bookmarks.md');
return normalized
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
.replace(/\s+/g, '_');
}
function buildBackupFileName(sourceName) {
const safeName = sanitizeBackupBaseName(sourceName);
const match = safeName.match(/^(.*?)(\.[^.]+)?$/);
const base = match?.[1] || 'bookmarks';
const ext = match?.[2] || '.md';
return `${base}.${buildBackupTimestamp(new Date())}.bak${ext}`;
}
function isBookmarkTableHeader(cells) {
if (!Array.isArray(cells) || cells.length !== 4) {
return false;
}
return (
cells[0] === 'No' &&
cells[1] === '标题' &&
cells[2] === '链接' &&
cells[3].toLowerCase() === 'remark'
);
}
function isMarkdownDividerRow(cells) {
return (
Array.isArray(cells) &&
cells.length === 4 &&
cells.every((cell) => /^:?-{3,}:?$/.test(cell))
);
}
function findBookmarkTables(lines) {
const tables = [];
for (let i = 0; i < lines.length - 1; i += 1) {
const headerCells = parseMarkdownTableLine(lines[i]);
const dividerCells = parseMarkdownTableLine(lines[i + 1]);
if (!isBookmarkTableHeader(headerCells) || !isMarkdownDividerRow(dividerCells)) {
continue;
}
let endIndex = i + 1;
for (let j = i + 2; j < lines.length; j += 1) {
const rowCells = parseMarkdownTableLine(lines[j]);
if (!rowCells) {
break;
}
endIndex = j;
}
tables.push({
headerIndex: i,
dividerIndex: i + 1,
endIndex
});
i = endIndex;
}
return tables;
}
function findBookmarkEntryByUrl(lines, targetUrl) {
const tableRanges = findBookmarkTables(lines);
const lastTableRange = tableRanges.length > 0 ? tableRanges[tableRanges.length - 1] : null;
if (!targetUrl) {
return {
tableRange: lastTableRange,
entry: null
};
}
for (const tableRange of tableRanges) {
for (let i = tableRange.dividerIndex + 1; i <= tableRange.endIndex; i += 1) {
const cells = parseMarkdownTableLine(lines[i]);
if (!cells || cells.length < 4 || !/^\d+$/.test(cells[0])) {
continue;
}
const rowUrl = extractUrlFromTableCell(cells[2]);
if (rowUrl !== targetUrl) {
continue;
}
return {
tableRange,
entry: {
lineIndex: i,
no: Number(cells[0]),
title: unescapeMarkdownTableText(cells[1]),
url: rowUrl,
remark: unescapeMarkdownTableText(cells[3])
}
};
}
}
return {
tableRange: lastTableRange,
entry: null
};
}
function getNextBookmarkNo(lines) {
let maxNo = 0;
const tableRanges = findBookmarkTables(lines);
for (const tableRange of tableRanges) {
for (let i = tableRange.dividerIndex + 1; i <= tableRange.endIndex; i += 1) {
const cells = parseMarkdownTableLine(lines[i]);
if (!cells || cells.length < 4 || !/^\d+$/.test(cells[0])) {
continue;
}
maxNo = Math.max(maxNo, Number(cells[0]));
}
}
return maxNo + 1;
}
function buildBookmarkRow(no, title, url, remark) {
return `| ${no} | ${escapeMarkdownTableText(title)} | <${url}> | ${escapeMarkdownTableText(remark)} |`;
}
function buildInitialDocument(row) {
return [
'# Bookmarks',
'',
TABLE_HEADER_ROW,
TABLE_DIVIDER_ROW,
row
].join('\n');
}
function buildUpdatedDocument(existingText, row) {
const normalizedText = String(existingText || '').replace(/\r\n/g, '\n');
if (!normalizedText.trim()) {
return `${buildInitialDocument(row)}\n`;
}
const lines = normalizedText.split('\n');
const tableRanges = findBookmarkTables(lines);
const tableRange = tableRanges.length > 0 ? tableRanges[tableRanges.length - 1] : null;
if (!tableRange) {
const spacer = normalizedText.endsWith('\n\n') ? '' : normalizedText.endsWith('\n') ? '\n' : '\n\n';
return `${normalizedText}${spacer}## Bookmark Table\n\n${TABLE_HEADER_ROW}\n${TABLE_DIVIDER_ROW}\n${row}\n`;
}
const updatedLines = [
...lines.slice(0, tableRange.endIndex + 1),
row,
...lines.slice(tableRange.endIndex + 1)
];
return updatedLines.join('\n');
}
function buildDocumentWithReplacedRow(existingText, lineIndex, row) {
const lines = String(existingText || '').replace(/\r\n/g, '\n').split('\n');
lines[lineIndex] = row;
return lines.join('\n');
}
function buildAppendPayload(existingText, row) {
const normalizedText = String(existingText || '').replace(/\r\n/g, '\n');
if (!normalizedText.trim()) {
return `${buildInitialDocument(row)}\n`;
}
const lines = normalizedText.split('\n');
const tableRanges = findBookmarkTables(lines);
const lastTableRange = tableRanges.length > 0 ? tableRanges[tableRanges.length - 1] : null;
const trailingNewlines = countTrailingNewlines(normalizedText);
const trailingLines = lastTableRange ? lines.slice(lastTableRange.endIndex + 1) : [];
const canAppendRowToLastTable =
Boolean(lastTableRange) &&
trailingNewlines <= 1 &&
trailingLines.every((line) => line === '');
if (canAppendRowToLastTable) {
return `${normalizedText.endsWith('\n') ? '' : '\n'}${row}\n`;
}
const spacer = normalizedText.endsWith('\n\n') ? '' : normalizedText.endsWith('\n') ? '\n' : '\n\n';
return `${spacer}## Bookmark Table\n\n${TABLE_HEADER_ROW}\n${TABLE_DIVIDER_ROW}\n${row}\n`;
}
function supportsFileSystemAccess() {
return typeof window.showOpenFilePicker === 'function';
}
function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
request.result.createObjectStore(STORE_NAME);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function loadStoredHandle() {
if (!window.indexedDB) {
return null;
}
try {
const db = await openDatabase();
return await new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readonly');
const request = transaction.objectStore(STORE_NAME).get(HANDLE_KEY);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
transaction.oncomplete = () => db.close();
transaction.onerror = () => {
db.close();
reject(transaction.error);
};
});
} catch (error) {
console.warn('[Bookmarker] Failed to load stored handle:', error);
return null;
}
}
async function saveHandle(handle) {
if (!window.indexedDB) {
return;
}
try {
const db = await openDatabase();
await new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
transaction.objectStore(STORE_NAME).put(handle, HANDLE_KEY);
transaction.oncomplete = () => {
db.close();
resolve();
};
transaction.onerror = () => {
db.close();
reject(transaction.error);
};
});
} catch (error) {
console.warn('[Bookmarker] Failed to persist file handle:', error);
}
}
async function pickTargetFile() {
if (!supportsFileSystemAccess()) {
throw new Error('当前环境不支持直接写入本地文件,请使用最新版 Chrome 或 Edge。');
}
const handles = await window.showOpenFilePicker({
multiple: false,
types: [
{
description: 'Markdown file',
accept: {
'text/markdown': ['.md', '.markdown'],
'text/plain': ['.md', '.markdown']
}
}
]
});
const handle = handles?.[0];
if (!handle) {
throw new Error('没有选中文件。');
}
await saveHandle(handle);
return handle;
}
async function getTargetFileHandle(forcePick) {
if (!supportsFileSystemAccess()) {
throw new Error('当前浏览器不支持本地文件写入,请使用最新版 Chrome 或 Edge。');
}
let handle = null;
if (!forcePick) {
handle = await loadStoredHandle();
}
if (!handle) {
handle = await pickTargetFile();
}
return handle;
}
async function appendTextToFile(handle, text) {
const file = await handle.getFile();
const writable = await handle.createWritable({ keepExistingData: true });
await writable.seek(file.size);
await writable.write(text);
await writable.close();
}
async function writeTextToFile(handle, text) {
const writable = await handle.createWritable();
await writable.write(text);
await writable.close();
}
async function createBackupSnapshot(handleName, text) {
if (typeof navigator.storage?.getDirectory !== 'function') {
throw new Error('当前浏览器不支持本地备份快照,已中止更新。');
}
const rootDirectory = await navigator.storage.getDirectory();
const backupDirectory = await rootDirectory.getDirectoryHandle(BACKUP_DIRECTORY_NAME, { create: true });
const backupHandle = await backupDirectory.getFileHandle(buildBackupFileName(handleName), { create: true });
const writable = await backupHandle.createWritable();
await writable.write(String(text || ''));
await writable.close();
return backupHandle.name;
}
async function saveBookmarkToFile(handle, options = {}) {
const file = await handle.getFile();
const existingText = await file.text();
const currentUrl = normalizeUrl(location.href);
const nextRemark = normalizeText(options.remark || '');
if (!currentUrl) {
throw new Error('当前页面链接无效,无法保存。');
}
const normalizedText = String(existingText || '').replace(/\r\n/g, '\n');
const lines = normalizedText.split('\n');
const { tableRange, entry } = findBookmarkEntryByUrl(lines, currentUrl);
if (entry && !options.allowRemarkUpdate) {
return {
action: 'duplicate'
};
}
if (entry && options.allowRemarkUpdate) {
if (entry.remark === nextRemark) {
return {
action: 'unchanged',
nextNo: entry.no
};
}
const backupName = await createBackupSnapshot(handle.name || 'bookmarks.md', existingText);
const row = buildBookmarkRow(entry.no, getPageTitle(), currentUrl, nextRemark);
const nextText = buildDocumentWithReplacedRow(existingText, entry.lineIndex, row);
await writeTextToFile(handle, nextText);
return {
action: 'updated',
nextNo: entry.no,
previousRemark: entry.remark,
backupName
};
}
const nextNo = getNextBookmarkNo(lines);
const row = buildBookmarkRow(nextNo, getPageTitle(), currentUrl, nextRemark);
const appendPayload = buildAppendPayload(existingText, row);
await appendTextToFile(handle, appendPayload);
return {
action: 'created',
nextNo
};
}
function setStatus(message, tone) {
const status = document.getElementById(STATUS_ID);
if (!status) {
return;
}
status.textContent = message;
status.dataset.tone = tone || 'info';
}
function setFileName(name) {
const fileNode = document.getElementById(FILE_ID);
if (!fileNode) {
return;
}
fileNode.textContent = name ? `文件: ${name}` : '文件: 未选择';
}
function setBusy(nextBusy) {
isBusy = nextBusy;
const saveButton = document.getElementById(SAVE_ID);
const saveWithRemarkButton = document.getElementById(SAVE_WITH_REMARK_ID);
const pickButton = document.getElementById(PICK_ID);
const remarkSaveButton = document.getElementById(REMARK_SAVE_ID);
const remarkCancelButton = document.getElementById(REMARK_CANCEL_ID);
if (saveButton) {
saveButton.disabled = nextBusy;
}
if (saveWithRemarkButton) {
saveWithRemarkButton.disabled = nextBusy;
}
if (pickButton) {
pickButton.disabled = nextBusy;
}
if (remarkSaveButton) {
remarkSaveButton.disabled = nextBusy;
}
if (remarkCancelButton) {
remarkCancelButton.disabled = nextBusy;
}
}
function setPanelOpen(nextOpen) {
isPanelOpen = Boolean(nextOpen);
const root = document.getElementById(ROOT_ID);
const panel = document.getElementById(PANEL_ID);
const toggleButton = document.getElementById(TOGGLE_ID);
if (root) {
root.dataset.open = isPanelOpen ? 'true' : 'false';
}
if (panel) {
panel.hidden = !isPanelOpen;
}
if (toggleButton) {
toggleButton.textContent = 'B';
toggleButton.setAttribute('aria-expanded', isPanelOpen ? 'true' : 'false');
toggleButton.title = isPanelOpen ? '收起 Bookmarker' : '展开 Bookmarker';
}
if (!isPanelOpen) {
setRemarkDialogOpen(false);
}
}
function togglePanel() {
setPanelOpen(!isPanelOpen);
}
function setRemarkDialogOpen(nextOpen) {
isRemarkDialogOpen = Boolean(nextOpen);
const dialog = document.getElementById(REMARK_DIALOG_ID);
if (dialog) {
dialog.hidden = !isRemarkDialogOpen;
}
if (!isRemarkDialogOpen) {
remarkDraftContext = null;
}
}
function openRemarkDialog(options) {
const input = document.getElementById(REMARK_INPUT_ID);
const hint = document.getElementById(REMARK_HINT_ID);
remarkDraftContext = {
handle: options.handle
};
if (hint) {
hint.textContent = options.hint || '输入备注后保存到 remark。';
}
if (input) {
input.value = options.initialRemark || '';
}
setPanelOpen(true);
setRemarkDialogOpen(true);
window.setTimeout(() => {
if (!input) {
return;
}
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
}, 0);
}
async function prepareRemarkSave() {
if (isBusy) {
return;
}
setPanelOpen(true);
setBusy(true);
setStatus('正在读取备注信息...', 'info');
try {
const handle = await getTargetFileHandle(false);
setFileName(handle.name || '');
const file = await handle.getFile();
const existingText = await file.text();
const currentUrl = normalizeUrl(location.href);
const lines = String(existingText || '').replace(/\r\n/g, '\n').split('\n');
const { entry } = findBookmarkEntryByUrl(lines, currentUrl);
if (entry) {
openRemarkDialog({
handle,
initialRemark: entry.remark,
hint: entry.remark
? '该链接已有备注,可修改后保存。'
: '该链接已存在,当前 remark 为空,可直接补充后保存。'
});
setStatus(entry.remark ? '已载入原备注。' : '可为现有链接补充备注。', 'info');
return;
}
openRemarkDialog({
handle,
initialRemark: '',
hint: '当前链接尚未保存,输入备注后会新增一行。'
});
setStatus('请输入备注后保存。', 'info');
} catch (error) {
if (error?.name === 'AbortError') {
setStatus('已取消选择文件。', 'info');
} else if (error?.name === 'NotAllowedError' || error?.name === 'SecurityError') {
console.error('[Bookmarker] Load remark failed:', error);
setStatus('目标文件权限不可用,请点击“更换目标文件”后重新选择。', 'error');
} else {
console.error('[Bookmarker] Load remark failed:', error);
setStatus(error?.message || '读取备注信息失败,请重试。', 'error');
}
} finally {
setBusy(false);
}
}
async function submitRemarkSave() {
if (isBusy || !remarkDraftContext?.handle) {
return;
}
const input = document.getElementById(REMARK_INPUT_ID);
const remark = normalizeText(input?.value || '');
setBusy(true);
setStatus('正在保存备注...', 'info');
try {
const handle = remarkDraftContext.handle;
const result = await saveBookmarkToFile(handle, {
remark,
allowRemarkUpdate: true
});
setFileName(handle.name || '');
setRemarkDialogOpen(false);
if (result.action === 'updated') {
setStatus(`已更新现有链接的备注,并创建备份 ${result.backupName}。`, 'success');
return;
}
if (result.action === 'unchanged') {
setStatus('备注未变化,文件未更新。', 'info');
return;
}
setStatus(`已保存到表格,第 ${result.nextNo} 条。`, 'success');
} catch (error) {
if (error?.name === 'NotAllowedError' || error?.name === 'SecurityError') {
console.error('[Bookmarker] Save remark failed:', error);
setStatus('目标文件权限不可用,请点击“更换目标文件”后重新选择。', 'error');
} else {
console.error('[Bookmarker] Save remark failed:', error);
setStatus(error?.message || '保存备注失败,请重试。', 'error');
}
} finally {
setBusy(false);
}
}
async function saveCurrentPage(options = {}) {
if (isBusy) {
return;
}
setPanelOpen(true);
setBusy(true);
setStatus(options.forcePick ? '请选择已有的 Markdown 文件...' : '正在保存当前页面...', 'info');
try {
const handle = await getTargetFileHandle(Boolean(options.forcePick));
setFileName(handle.name || '');
setStatus('已获取目标文件,正在写入 Markdown...', 'info');
const result = await saveBookmarkToFile(handle);
if (result.action === 'duplicate') {
setStatus('该链接已存在,未更新文件。', 'error');
return;
}
setStatus(`已保存到表格,第 ${result.nextNo} 条。`, 'success');
} catch (error) {
if (error?.name === 'AbortError') {
setStatus('已取消选择文件。', 'info');
} else if (error?.name === 'NotAllowedError' || error?.name === 'SecurityError') {
console.error('[Bookmarker] Save failed:', error);
setStatus('目标文件权限不可用,请点击“更换目标文件”后重新选择。', 'error');
} else {
console.error('[Bookmarker] Save failed:', error);
setStatus(error?.message || '保存失败,请重试。', 'error');
}
} finally {
setBusy(false);
}
}
function isEditableTarget(target) {
if (!target) {
return false;
}
const tagName = String(target.tagName || '').toUpperCase();
return (
target.isContentEditable ||
tagName === 'INPUT' ||
tagName === 'TEXTAREA' ||
tagName === 'SELECT'
);
}
function handleKeydown(event) {
if (event.key === 'Escape' && isRemarkDialogOpen) {
setRemarkDialogOpen(false);
return;
}
if (event.key === 'Escape' && isPanelOpen) {
setPanelOpen(false);
return;
}
if (isEditableTarget(event.target)) {
return;
}
if (event.altKey && event.shiftKey && event.code === 'KeyB') {
event.preventDefault();
saveCurrentPage();
}
}
function createStyle() {
if (document.getElementById(`${APP_ID}-style`)) {
return;
}
const style = document.createElement('style');
style.id = `${APP_ID}-style`;
style.textContent = `
#${ROOT_ID} {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 2147483647;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#${ROOT_ID} * {
box-sizing: border-box;
}
#${TOGGLE_ID} {
border: none;
width: 46px;
height: 46px;
border-radius: 999px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
cursor: pointer;
color: #ffffff;
background: linear-gradient(135deg, #0f766e, #0d9488);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.24);
transition: transform 0.15s ease, opacity 0.15s ease;
}
#${TOGGLE_ID}:hover {
transform: translateY(-1px);
}
#${TOGGLE_ID}:disabled {
opacity: 0.6;
cursor: wait;
transform: none;
}
#${PANEL_ID} {
width: 200px;
padding: 12px;
border-radius: 14px;
background: rgba(18, 18, 18, 0.92);
color: #f5f5f5;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.24);
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(10px);
}
#${PANEL_ID}[hidden] {
display: none !important;
}
#${REMARK_DIALOG_ID} {
width: 240px;
padding: 12px;
border-radius: 14px;
background: rgba(18, 18, 18, 0.96);
color: #f5f5f5;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.24);
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(10px);
}
#${REMARK_DIALOG_ID}[hidden] {
display: none !important;
}
#${REMARK_HINT_ID} {
margin: 6px 0 10px;
font-size: 12px;
line-height: 1.5;
color: #cbd5e1;
}
#${REMARK_INPUT_ID} {
width: 100%;
min-height: 90px;
resize: vertical;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
color: #f8fafc;
padding: 10px 12px;
font-size: 13px;
line-height: 1.5;
outline: none;
font-family: inherit;
}
#${REMARK_INPUT_ID}:focus {
border-color: rgba(37, 99, 235, 0.85);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);
}
#${REMARK_DIALOG_ID} .${APP_ID}-remark-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
#${REMARK_DIALOG_ID} .${APP_ID}-remark-actions button {
flex: 1 1 0;
border: none;
border-radius: 10px;
padding: 10px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s ease, opacity 0.15s ease, background 0.15s ease;
}
#${REMARK_DIALOG_ID} .${APP_ID}-remark-actions button:hover {
transform: translateY(-1px);
}
#${REMARK_DIALOG_ID} .${APP_ID}-remark-actions button:disabled {
opacity: 0.6;
cursor: wait;
transform: none;
}
#${REMARK_SAVE_ID} {
background: linear-gradient(135deg, #1d4ed8, #2563eb);
color: #ffffff;
}
#${REMARK_CANCEL_ID} {
background: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
#${TITLE_ID} {
font-size: 13px;
font-weight: 700;
margin-bottom: 4px;
}
#${FILE_ID} {
font-size: 12px;
line-height: 1.4;
color: #d4d4d4;
margin-bottom: 10px;
word-break: break-word;
}
#${PANEL_ID} button {
width: 100%;
border: none;
border-radius: 10px;
padding: 10px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s ease, opacity 0.15s ease, background 0.15s ease;
}
#${PANEL_ID} button + button {
margin-top: 8px;
}
#${PANEL_ID} button:hover {
transform: translateY(-1px);
}
#${PANEL_ID} button:disabled {
opacity: 0.6;
cursor: wait;
transform: none;
}
#${SAVE_ID} {
background: linear-gradient(135deg, #0f766e, #0d9488);
color: #ffffff;
}
#${SAVE_WITH_REMARK_ID} {
background: linear-gradient(135deg, #1d4ed8, #2563eb);
color: #ffffff;
}
#${PICK_ID} {
background: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
#${STATUS_ID} {
min-height: 18px;
margin-top: 10px;
font-size: 12px;
line-height: 1.5;
color: #d1d5db;
}
#${STATUS_ID}[data-tone="success"] {
color: #86efac;
}
#${STATUS_ID}[data-tone="error"] {
color: #fca5a5;
}
@media (max-width: 768px) {
#${ROOT_ID} {
right: 12px;
left: 12px;
bottom: 12px;
align-items: stretch;
}
#${TOGGLE_ID} {
width: 100%;
}
#${PANEL_ID} {
width: 100%;
}
#${REMARK_DIALOG_ID} {
width: 100%;
}
}
`;
document.documentElement.appendChild(style);
}
function createApp() {
if (document.getElementById(ROOT_ID)) {
return;
}
const root = document.createElement('div');
root.id = ROOT_ID;
root.dataset.open = 'false';
const toggleButton = document.createElement('button');
toggleButton.id = TOGGLE_ID;
toggleButton.type = 'button';
toggleButton.textContent = 'B';
toggleButton.title = '展开 Bookmarker';
toggleButton.setAttribute('aria-expanded', 'false');
toggleButton.addEventListener('click', () => {
togglePanel();
});
const panel = document.createElement('div');
panel.id = PANEL_ID;
panel.hidden = true;
const remarkDialog = document.createElement('div');
remarkDialog.id = REMARK_DIALOG_ID;
remarkDialog.hidden = true;
const remarkTitle = document.createElement('div');
remarkTitle.id = TITLE_ID + '-remark';
remarkTitle.textContent = '编辑备注';
remarkTitle.style.fontSize = '13px';
remarkTitle.style.fontWeight = '700';
const remarkHint = document.createElement('div');
remarkHint.id = REMARK_HINT_ID;
remarkHint.textContent = '输入备注后保存到 remark。';
const remarkInput = document.createElement('textarea');
remarkInput.id = REMARK_INPUT_ID;
remarkInput.placeholder = '输入备注内容';
const remarkActions = document.createElement('div');
remarkActions.className = `${APP_ID}-remark-actions`;
const remarkSaveButton = document.createElement('button');
remarkSaveButton.id = REMARK_SAVE_ID;
remarkSaveButton.type = 'button';
remarkSaveButton.textContent = '保存备注';
remarkSaveButton.addEventListener('click', () => {
submitRemarkSave();
});
const remarkCancelButton = document.createElement('button');
remarkCancelButton.id = REMARK_CANCEL_ID;
remarkCancelButton.type = 'button';
remarkCancelButton.textContent = '取消';
remarkCancelButton.addEventListener('click', () => {
setRemarkDialogOpen(false);
setStatus('已取消编辑备注。', 'info');
});
remarkActions.appendChild(remarkSaveButton);
remarkActions.appendChild(remarkCancelButton);
remarkDialog.appendChild(remarkTitle);
remarkDialog.appendChild(remarkHint);
remarkDialog.appendChild(remarkInput);
remarkDialog.appendChild(remarkActions);
const title = document.createElement('div');
title.id = TITLE_ID;
title.textContent = 'Bookmarker';
const file = document.createElement('div');
file.id = FILE_ID;
file.textContent = '文件: 未选择';
const saveButton = document.createElement('button');
saveButton.id = SAVE_ID;
saveButton.type = 'button';
saveButton.textContent = '保存当前页面';
saveButton.addEventListener('click', () => {
saveCurrentPage();
});
const saveWithRemarkButton = document.createElement('button');
saveWithRemarkButton.id = SAVE_WITH_REMARK_ID;
saveWithRemarkButton.type = 'button';
saveWithRemarkButton.textContent = '保存页面(带备注)';
saveWithRemarkButton.addEventListener('click', () => {
prepareRemarkSave();
});
const pickButton = document.createElement('button');
pickButton.id = PICK_ID;
pickButton.type = 'button';
pickButton.textContent = '更换目标文件';
pickButton.addEventListener('click', () => {
saveCurrentPage({ forcePick: true });
});
const status = document.createElement('div');
status.id = STATUS_ID;
status.textContent = '手动点击保存,或按 Alt + Shift + B。';
panel.appendChild(title);
panel.appendChild(file);
panel.appendChild(saveButton);
panel.appendChild(saveWithRemarkButton);
panel.appendChild(pickButton);
panel.appendChild(status);
root.appendChild(remarkDialog);
root.appendChild(panel);
root.appendChild(toggleButton);
document.documentElement.appendChild(root);
}
function handlePointerDown(event) {
if (!isPanelOpen) {
return;
}
const root = document.getElementById(ROOT_ID);
if (root && !root.contains(event.target)) {
setPanelOpen(false);
}
}
async function hydrateStoredFileName() {
const handle = await loadStoredHandle();
if (handle?.name) {
setFileName(handle.name);
setStatus('已记住目标文件,可直接保存。', 'info');
}
}
function bootstrap() {
createStyle();
createApp();
document.addEventListener('keydown', handleKeydown, true);
document.addEventListener('pointerdown', handlePointerDown, true);
hydrateStoredFileName();
}
bootstrap();
})();