${escapeHTML(shorten(item.quote, 240))}${editorOpen ? `` : (item.note.trim() ? `
${escapeHTML(item.note)}
` : '')}// ==UserScript== // @name MarkWeb - 网页笔记与高亮 // @namespace https://github.com/AexZero/markweb // @version 1.15.0 // @description 选中文字即可高亮、添加备注并永久保存,支持批注管理和 Markdown 导出。 // @author AexZero // @match http://*/* // @match https://*/* // @exclude http://0.0.0.0:8999/* // @exclude *://google.com/* // @exclude *://*.google.com/* // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_registerMenuCommand // ==/UserScript== (function () { 'use strict'; const APP_ID = 'markweb-app'; const MARK_CLASS = 'markweb-highlight'; const STORAGE_PREFIX = 'markweb:page:v1:'; const CATALOG_KEY = 'markweb:catalog:v1'; const SETTINGS_KEY = 'markweb:settings:v1'; const CONTEXT_LENGTH = 48; const DEFAULT_COLORS = ['#ffddea', '#c3faff', '#ffffdd', '#ffe2ff', '#c7fff0', '#fdf89b']; const LEGACY_COLOR_SLOTS = { '#facd5a': 0, '#69b0f2': 1, '#b8d194': 2, '#fd9de7': 3, coral: 4, '#fdf89b': 5 }; const DEFAULT_COLOR = DEFAULT_COLORS[0]; const MARGIN_FONT_OPTIONS = [ { id: 'xingkai', label: '行楷-简', stack: '"行楷-简","Xingkai SC",STXingkai,"Kaiti SC",STKaiti,KaiTi,cursive' }, { id: 'kaiti', label: '楷体', stack: '"Kaiti SC",STKaiti,KaiTi,serif' }, { id: 'songti', label: '宋体', stack: '"Songti SC",STSong,SimSun,serif' }, { id: 'heiti', label: '黑体', stack: '"PingFang SC","Microsoft YaHei",sans-serif' }, { id: 'system', label: '系统字体', stack: '-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif' }, { id: 'cursive', label: '手写体', stack: 'cursive' }, { id: 'custom', label: '自定义字体', stack: '' } ]; const DEFAULT_SETTINGS = { version: 2, palette: DEFAULT_COLORS.slice(), marginNotes: { enabled: true, fontSize: 18, distance: 32, side: 'auto', font: 'xingkai', customFont: '' } }; let activePageKey = getPageKey(); let pageState = emptyState(); let pendingSelection = null; let activeNoteId = null; let searchText = ''; let panelMode = 'page'; let settingsReturnMode = 'page'; let allPages = []; let allPagesLoading = false; let allPagesLoadToken = 0; const expandedNoteIds = new Set(); let deletedSnapshot = null; let settings = cloneDefaultSettings(); let saveTimer; let settingsSaveTimer; let cardClickTimer; let highlightRestoreTimer; let marginLayoutFrame; let mutationTimer; let pageResizeObserver; let pageMutationObserver; const ui = createUI(); injectPageStyle(); bindEvents(); registerScriptMenu(); void boot(); async function boot() { const requestedKey = activePageKey; const [savedSettings, savedPage] = await Promise.all([ getStored(SETTINGS_KEY, null), getStored(STORAGE_PREFIX + requestedKey, null) ]); settings = normalizeSettings(savedSettings); syncMarginNotesToggle(); syncPaletteButtons(); scheduleMarginNoteLayout(); if (requestedKey === activePageKey) applyLoadedPage(savedPage, requestedKey); observePageLayout(); } function cloneDefaultSettings() { return { version: DEFAULT_SETTINGS.version, palette: DEFAULT_SETTINGS.palette.slice(), marginNotes: { ...DEFAULT_SETTINGS.marginNotes } }; } function normalizeSettings(saved) { const defaults = cloneDefaultSettings(); const legacyMarginEnabled = typeof saved?.marginNotes === 'boolean' ? saved.marginNotes : undefined; const palette = Array.isArray(saved?.palette) && saved.palette.length === DEFAULT_COLORS.length ? saved.palette.map((color, index) => normalizeHexColor(color) || defaults.palette[index]) : defaults.palette; const margin = saved?.marginNotes && typeof saved.marginNotes === 'object' ? saved.marginNotes : {}; return { version: 2, palette, marginNotes: { enabled: typeof margin.enabled === 'boolean' ? margin.enabled : (legacyMarginEnabled ?? defaults.marginNotes.enabled), fontSize: clampNumber(margin.fontSize, 12, 30, defaults.marginNotes.fontSize), distance: clampNumber(margin.distance, 8, 120, defaults.marginNotes.distance), side: ['auto', 'left', 'right'].includes(margin.side) ? margin.side : defaults.marginNotes.side, font: MARGIN_FONT_OPTIONS.some((option) => option.id === margin.font) ? margin.font : defaults.marginNotes.font, customFont: typeof margin.customFont === 'string' ? margin.customFont.slice(0, 80) : defaults.marginNotes.customFont } }; } function marginNoteFontStack() { if (settings.marginNotes.font === 'custom') { const fontName = settings.marginNotes.customFont.trim().replace(/["\\]/g, ''); if (fontName) return `"${fontName}",cursive`; } return MARGIN_FONT_OPTIONS.find((option) => option.id === settings.marginNotes.font)?.stack || MARGIN_FONT_OPTIONS[0].stack; } function clampNumber(value, minimum, maximum, fallback) { const number = Number(value); return Number.isFinite(number) ? Math.max(minimum, Math.min(maximum, number)) : fallback; } function normalizeHexColor(value) { const match = String(value || '').trim().match(/^#([\da-f]{3}|[\da-f]{6})$/i); if (!match) return null; const hex = match[1].length === 3 ? Array.from(match[1], (char) => char + char).join('') : match[1]; return `#${hex.toLowerCase()}`; } function getPageKey() { const url = new URL(location.href); url.hash = ''; return url.href; } function emptyState() { return { version: 1, url: getPageKey(), title: document.title, updatedAt: new Date().toISOString(), notes: [] }; } function withTimeout(value, milliseconds, message) { return new Promise((resolve, reject) => { const timer = window.setTimeout(() => reject(new Error(message)), milliseconds); Promise.resolve(value).then( (result) => { window.clearTimeout(timer); resolve(result); }, (error) => { window.clearTimeout(timer); reject(error); } ); }); } async function getStored(key, fallback) { try { if (typeof GM_getValue === 'function') { return await withTimeout(GM_getValue(key, fallback), 6000, '读取笔记超时'); } const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : fallback; } catch (error) { console.warn('[MarkWeb] 读取失败:', error); return fallback; } } async function setStored(key, value) { try { if (typeof GM_setValue === 'function') { await withTimeout(GM_setValue(key, value), 6000, '保存笔记超时'); } else localStorage.setItem(key, JSON.stringify(value)); } catch (error) { console.warn('[MarkWeb] 保存失败:', error); toast('保存失败,请检查脚本权限'); } } async function deleteStored(key) { try { if (typeof GM_deleteValue === 'function') { await withTimeout(GM_deleteValue(key), 6000, '删除笔记数据超时'); } else { localStorage.removeItem(key); } } catch (error) { console.warn('[MarkWeb] 删除存储失败:', error); toast('删除存储失败,请检查脚本权限'); } } async function listStoredKeys() { try { if (typeof GM_listValues === 'function') { return await withTimeout(GM_listValues(), 6000, '读取笔记列表超时'); } return Object.keys(localStorage); } catch (error) { console.warn('[MarkWeb] 获取笔记索引失败:', error); toast('读取全部笔记失败,请刷新后重试'); return []; } } async function loadAllPages() { if (allPagesLoading) return; const loadToken = ++allPagesLoadToken; allPagesLoading = true; renderPanel(); const watchdog = window.setTimeout(() => { if (loadToken !== allPagesLoadToken) return; allPagesLoadToken += 1; allPagesLoading = false; renderPanel(); toast('读取超过 10 秒,已自动停止,请重试'); }, 10000); try { // 先写入当前页的最新状态,保证总览不会漏掉刚刚添加的笔记。 pageState.title = document.title; pageState.updatedAt = new Date().toISOString(); const currentStorageKey = STORAGE_PREFIX + activePageKey; const existingCurrentPage = await getStored(currentStorageKey, null); if (pageState.notes.length || existingCurrentPage) { await savePageState(activePageKey, pageState); } const catalog = await getStored(CATALOG_KEY, null); const hasCompleteCatalog = catalog?.migrated === true && Array.isArray(catalog.pages); const keys = hasCompleteCatalog ? [...new Set(catalog.pages.map((item) => item?.key).filter((key) => typeof key === 'string' && key.startsWith(STORAGE_PREFIX)))] : (await listStoredKeys()).filter((key) => key.startsWith(STORAGE_PREFIX)); const pages = await Promise.all(keys.map(async (key) => { const saved = await getStored(key, null); if (!saved || !Array.isArray(saved.notes)) return null; const notes = saved.notes.filter(validNote).map((item) => { const colorId = resolveColorId(item); return { ...item, colorId, color: settings.palette[colorId], note: typeof item.note === 'string' ? item.note : '' }; }); if (!notes.length) { await deleteStored(key); return null; } return { storageKey: key, url: typeof saved.url === 'string' ? saved.url : key.slice(STORAGE_PREFIX.length), title: typeof saved.title === 'string' && saved.title ? saved.title : '未命名网页', updatedAt: saved.updatedAt || '', notes }; })); if (loadToken !== allPagesLoadToken) return; allPages = pages.filter(Boolean).sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))); await setStored(CATALOG_KEY, { version: 1, migrated: true, pages: allPages.map((page) => ({ key: page.storageKey, url: page.url, title: page.title, updatedAt: page.updatedAt, noteCount: page.notes.length })) }); } catch (error) { console.warn('[MarkWeb] 全部笔记加载失败:', error); if (loadToken === allPagesLoadToken) toast('全部笔记加载失败,请刷新后重试'); } finally { window.clearTimeout(watchdog); if (loadToken === allPagesLoadToken) { allPagesLoading = false; renderPanel(); } } } async function loadPage() { const requestedKey = activePageKey; const saved = await getStored(STORAGE_PREFIX + requestedKey, null); applyLoadedPage(saved, requestedKey); } function applyLoadedPage(saved, requestedKey) { if (requestedKey !== activePageKey) return; pageState = normalizeState(saved); restoreHighlights(); renderPanel(); window.setTimeout(() => { if (requestedKey === activePageKey) restoreHighlights(); }, 1500); } function normalizeState(saved) { const state = saved && typeof saved === 'object' ? saved : emptyState(); return { version: 1, url: activePageKey, title: typeof state.title === 'string' ? state.title : document.title, updatedAt: state.updatedAt || new Date().toISOString(), notes: Array.isArray(state.notes) ? state.notes.filter(validNote).map((item) => { const colorId = resolveColorId(item); return { id: item.id, quote: item.quote, prefix: item.prefix || '', suffix: item.suffix || '', position: Number.isFinite(item.position) ? item.position : 0, colorId, color: settings.palette[colorId], note: typeof item.note === 'string' ? item.note : '', createdAt: item.createdAt || new Date().toISOString(), updatedAt: item.updatedAt || item.createdAt || new Date().toISOString() }; }) : [] }; } function validNote(item) { return item && typeof item.id === 'string' && typeof item.quote === 'string' && item.quote.length; } function resolveColorId(item) { const storedId = Number(item?.colorId); if (Number.isInteger(storedId) && storedId >= 0 && storedId < DEFAULT_COLORS.length) return storedId; const rawColor = String(item?.color || '').trim().toLowerCase(); if (Number.isInteger(LEGACY_COLOR_SLOTS[rawColor])) return LEGACY_COLOR_SLOTS[rawColor]; const color = normalizeHexColor(item?.color); if (!color) return 0; const currentIndex = settings.palette.indexOf(color); if (currentIndex >= 0) return currentIndex; const defaultIndex = DEFAULT_COLORS.indexOf(color); if (defaultIndex >= 0) return defaultIndex; const target = hexToRgb(color); return settings.palette .map((candidate, index) => { const rgb = hexToRgb(candidate); const distance = (rgb.r - target.r) ** 2 + (rgb.g - target.g) ** 2 + (rgb.b - target.b) ** 2; return { index, distance }; }) .sort((a, b) => a.distance - b.distance)[0]?.index || 0; } function noteHighlightColor(note) { return settings.palette[resolveColorId(note)] || settings.palette[0] || DEFAULT_COLOR; } function hexToRgb(color) { const normalized = normalizeHexColor(color) || DEFAULT_COLOR; return { r: Number.parseInt(normalized.slice(1, 3), 16), g: Number.parseInt(normalized.slice(3, 5), 16), b: Number.parseInt(normalized.slice(5, 7), 16) }; } function rgbToHex(red, green, blue) { return `#${[red, green, blue].map((value) => Math.round(Math.max(0, Math.min(255, value))).toString(16).padStart(2, '0')).join('')}`; } function accentColor(color) { const { r, g, b } = hexToRgb(color); const red = r / 255; const green = g / 255; const blue = b / 255; const maximum = Math.max(red, green, blue); const minimum = Math.min(red, green, blue); const delta = maximum - minimum; let hue = 0; if (delta) { if (maximum === red) hue = ((green - blue) / delta) % 6; else if (maximum === green) hue = (blue - red) / delta + 2; else hue = (red - green) / delta + 4; hue = (hue * 60 + 360) % 360; } const lightness = (maximum + minimum) / 2; const saturation = delta ? delta / (1 - Math.abs(2 * lightness - 1)) : 0; const targetSaturation = delta ? Math.max(0.76, Math.min(1, saturation * 1.08)) : 0; const targetLightness = hue >= 42 && hue <= 78 ? 0.38 : 0.48; return hslToHex(hue, targetSaturation, targetLightness); } function hslToHex(hue, saturation, lightness) { const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; const part = hue / 60; const intermediate = chroma * (1 - Math.abs(part % 2 - 1)); let values = [0, 0, 0]; if (part < 1) values = [chroma, intermediate, 0]; else if (part < 2) values = [intermediate, chroma, 0]; else if (part < 3) values = [0, chroma, intermediate]; else if (part < 4) values = [0, intermediate, chroma]; else if (part < 5) values = [intermediate, 0, chroma]; else values = [chroma, 0, intermediate]; const match = lightness - chroma / 2; return rgbToHex(...values.map((value) => (value + match) * 255)); } function contrastTextColor(color) { const background = relativeLuminance(color); const dark = relativeLuminance('#1d1d1f'); const light = relativeLuminance('#ffffff'); const darkContrast = (Math.max(background, dark) + 0.05) / (Math.min(background, dark) + 0.05); const lightContrast = (Math.max(background, light) + 0.05) / (Math.min(background, light) + 0.05); return darkContrast >= lightContrast ? '#1d1d1f' : '#ffffff'; } function relativeLuminance(color) { const { r, g, b } = hexToRgb(color); const channels = [r, g, b].map((value) => { const channel = value / 255; return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; }); return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; } function scheduleSave() { window.clearTimeout(saveTimer); const key = activePageKey; saveTimer = window.setTimeout(() => { if (key !== activePageKey) return; pageState.title = document.title; pageState.updatedAt = new Date().toISOString(); void savePageState(key, pageState); }, 120); } function scheduleSettingsSave() { window.clearTimeout(settingsSaveTimer); settingsSaveTimer = window.setTimeout(() => { settingsSaveTimer = 0; void setStored(SETTINGS_KEY, settings); }, 150); } async function savePageState(url, state) { const storageKey = STORAGE_PREFIX + url; if (Array.isArray(state.notes) && state.notes.length) { await setStored(storageKey, state); } else { await deleteStored(storageKey); } await updateCatalog(storageKey, state); } async function updateCatalog(storageKey, state) { const savedCatalog = await getStored(CATALOG_KEY, null); const catalog = savedCatalog && Array.isArray(savedCatalog.pages) ? savedCatalog : { version: 1, migrated: false, pages: [] }; const pages = catalog.pages.filter((item) => item && item.key !== storageKey); if (Array.isArray(state.notes) && state.notes.length) { pages.push({ key: storageKey, url: state.url, title: state.title, updatedAt: state.updatedAt, noteCount: state.notes.length }); } await setStored(CATALOG_KEY, { ...catalog, pages }); } function injectPageStyle() { if (document.getElementById('markweb-page-style')) return; const style = document.createElement('style'); style.id = 'markweb-page-style'; style.textContent = ` mark.${MARK_CLASS} { background: var(--markweb-color, ${DEFAULT_COLOR}) !important; color: var(--markweb-text-color, #1d1d1f) !important; -webkit-text-fill-color: var(--markweb-text-color, #1d1d1f) !important; text-shadow: none !important; padding-left: 0.25rem !important; padding-right: 0.25rem !important; border-radius: 0.2rem !important; border: 1px solid #ede !important; box-decoration-break: clone; -webkit-box-decoration-break: clone; cursor: pointer !important; transition: filter 120ms ease, box-shadow 120ms ease; } mark.${MARK_CLASS}:hover, mark.${MARK_CLASS}:focus-visible { filter: saturate(1.12) brightness(.96); box-shadow: 0 0 0 2px var(--markweb-color, ${DEFAULT_COLOR}); outline: none !important; } `; (document.head || document.documentElement).appendChild(style); } function createUI() { const host = document.createElement('div'); host.id = APP_ID; const root = host.attachShadow({ mode: 'open' }); const style = document.createElement('style'); style.textContent = ` :host { all: initial; } * { box-sizing: border-box; } button, input, textarea, select { font: inherit; } .layer { position:fixed; inset:0; z-index:2147483647; pointer-events:none; color:#242424; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif; } .margin-connectors { position:fixed; inset:0; z-index:1; width:100vw; height:100vh; overflow:visible; pointer-events:none; } .margin-notes { position:fixed; inset:0; z-index:2; pointer-events:none; } .margin-note { position:fixed; display:-webkit-box; max-height:128px; padding:2px 4px; overflow:hidden; color:var(--margin-color); background:transparent; border:0; border-radius:0; box-shadow:none; font-size:18px; font-weight:600; line-height:1.55; letter-spacing:.025em; text-align:left; white-space:pre-wrap; overflow-wrap:anywhere; -webkit-box-orient:vertical; -webkit-line-clamp:4; cursor:pointer; pointer-events:auto; transition:filter 120ms ease,opacity 120ms ease; } .margin-note:hover { background:transparent; filter:saturate(1.15) brightness(.86); } .margin-note.active { text-decoration:underline; text-decoration-thickness:1px; text-underline-offset:4px; } .palette { position:fixed; z-index:6; display:none; align-items:center; gap:8px; min-height:34px; padding:5px 8px; background:linear-gradient(135deg,rgba(18,21,28,.84),rgba(4,6,10,.76)); border:1px solid rgba(255,255,255,.16); border-radius:11px; box-shadow:0 14px 38px rgba(0,0,0,.46),inset 0 1px 0 rgba(255,255,255,.08); backdrop-filter:blur(24px) saturate(160%); -webkit-backdrop-filter:blur(24px) saturate(160%); pointer-events:auto; } .palette.visible { display:flex; animation:appear 120ms ease-out; } @keyframes appear { from { opacity:0; transform:translateY(4px) scale(.98); } } .color-button { width:20px; height:20px; padding:0; border:1.5px solid rgba(255,255,255,.72); border-radius:50%; cursor:pointer; transition:transform 100ms ease; } .color-button:hover,.color-button:focus-visible { transform:scale(1.16); outline:none; } .panel { position:fixed; z-index:5; top:14px; right:14px; bottom:14px; width:min(370px,calc(100vw - 28px)); display:flex; flex-direction:column; overflow:hidden; pointer-events:auto; background:rgba(250,250,252,.92); border:1px solid rgba(255,255,255,.72); border-radius:16px; box-shadow:0 20px 60px rgba(0,0,0,.18),inset 0 1px 0 rgba(255,255,255,.62); -webkit-backdrop-filter:saturate(180%) blur(20px); backdrop-filter:saturate(180%) blur(20px); transform:translateX(calc(100% + 24px)); transition:transform 180ms ease; } .panel.open { transform:translateX(0); } .panel-header { padding:10px 12px; background:rgba(250,250,252,.42); border-bottom:1px solid rgba(0,0,0,.08); } .header-row { display:grid; grid-template-columns:30px minmax(0,1fr) 30px; align-items:center; gap:8px; } .count { color:#777; font-size:12px; text-align:center; white-space:nowrap; } .icon-button,.text-button { border:1px solid rgba(0,0,0,.11); background:rgba(255,255,255,.62); color:#333; border-radius:8px; cursor:pointer; } .icon-button { display:flex; align-items:center; justify-content:center; width:30px; height:30px; padding:0; font-size:18px; line-height:0; } .icon-button svg { display:block; width:16px; height:16px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; } .text-button { padding:6px 10px; font-size:12px; } .icon-button:hover,.text-button:hover { background:rgba(255,255,255,.9); border-color:rgba(0,0,0,.2); } .clear-button:hover { color:#cf1322; background:#fff1f0; border-color:#ffccc7; } .settings-wrap { position:relative; } .settings-menu { position:absolute; top:calc(100% + 7px); right:0; display:none; width:168px; padding:5px; background:rgba(250,250,252,.82); border:1px solid rgba(255,255,255,.72); border-radius:10px; box-shadow:0 12px 32px rgba(0,0,0,.18),inset 0 1px 0 rgba(255,255,255,.55); -webkit-backdrop-filter:saturate(180%) blur(20px); backdrop-filter:saturate(180%) blur(20px); z-index:3; } .settings-menu.open { display:block; animation:appear 100ms ease-out; } .menu-item { display:flex; align-items:center; gap:9px; width:100%; padding:8px 9px; border:0; border-radius:7px; color:#333; background:transparent; cursor:pointer; text-align:left; } .menu-item:hover { background:rgba(255,255,255,.72); } .menu-item svg { flex:none; width:16px; height:16px; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; } .menu-state { margin-left:auto; color:#777; font-size:11px; } .search { display:none; width:100%; margin-top:10px; padding:8px 10px; border:1px solid rgba(0,0,0,.12); border-radius:9px; outline:none; background:rgba(255,255,255,.68); } .search.open { display:block; animation:search-in 120ms ease-out; } @keyframes search-in { from { opacity:0; transform:translateY(-3px); } } .search:focus,.note-input:focus { border-color:#69b1ff; box-shadow:0 0 0 2px #e6f4ff; } .notes { flex:1; overflow:auto; padding:12px; overscroll-behavior:contain; } .empty { padding:48px 20px; color:#888; text-align:center; } .settings-page { display:grid; gap:12px; } .settings-section { padding:13px; border:1px solid rgba(0,0,0,.075); border-radius:12px; background:rgba(255,255,255,.58); } .settings-section h3 { margin:0 0 12px; font-size:14px; } .settings-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:12px; min-height:34px; } .settings-row + .settings-row { margin-top:10px; } .settings-label { min-width:0; } .settings-range-row { grid-template-columns:1fr; gap:7px; } .settings-range-head { display:flex; align-items:center; justify-content:space-between; gap:12px; } .settings-range { display:flex; align-items:center; width:100%; } .settings-range input[type="range"] { --range-progress:0%; --range-rest:rgba(0,0,0,.12); appearance:none; -webkit-appearance:none; width:100%; height:20px; margin:0; padding:0; background:transparent; cursor:pointer; } .settings-range input[type="range"]::-webkit-slider-runnable-track { height:5px; background:linear-gradient(to right,#1677ff 0 var(--range-progress),var(--range-rest) var(--range-progress) 100%); border-radius:999px; } .settings-range input[type="range"]::-webkit-slider-thumb { width:17px; height:17px; margin-top:-6px; appearance:none; -webkit-appearance:none; background:#fff; border:2px solid #1677ff; border-radius:50%; box-shadow:0 1px 4px rgba(0,0,0,.2); transition:transform 100ms ease,box-shadow 100ms ease; } .settings-range input[type="range"]:hover::-webkit-slider-thumb,.settings-range input[type="range"]:focus-visible::-webkit-slider-thumb { transform:scale(1.1); box-shadow:0 0 0 4px rgba(22,119,255,.14); } .settings-range input[type="range"]::-moz-range-track { height:5px; background:var(--range-rest); border-radius:999px; } .settings-range input[type="range"]::-moz-range-progress { height:5px; background:#1677ff; border-radius:999px; } .settings-range input[type="range"]::-moz-range-thumb { width:15px; height:15px; background:#fff; border:2px solid #1677ff; border-radius:50%; box-shadow:0 1px 4px rgba(0,0,0,.2); } .settings-row select,.settings-text-input { min-width:150px; padding:7px 28px 7px 9px; color:#333; background:rgba(255,255,255,.74); border:1px solid rgba(0,0,0,.12); border-radius:8px; outline:none; } .settings-text-input { width:170px; padding-right:9px; } .settings-row select:focus,.settings-text-input:focus { border-color:#69b1ff; box-shadow:0 0 0 2px #e6f4ff; } .settings-value { min-width:48px; padding:2px 7px; color:#555; background:rgba(0,0,0,.055); border-radius:999px; font-size:11px; text-align:center; } .switch-input { width:18px; height:18px; accent-color:#1677ff; cursor:pointer; } .palette-settings { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .palette-setting { display:grid; grid-template-columns:34px minmax(0,1fr); align-items:center; gap:8px; padding:7px; border:1px solid rgba(0,0,0,.07); border-radius:9px; background:rgba(255,255,255,.45); } .palette-input-wrap { position:relative; display:grid; place-items:center; width:34px; height:34px; color:var(--preview-text); border-radius:9px; background:var(--preview-color); box-shadow:inset 0 0 0 1px rgba(0,0,0,.1); overflow:hidden; } .palette-input-wrap::after { content:"Aa"; font-size:11px; font-weight:700; pointer-events:none; } .palette-input { position:absolute; inset:-8px; width:50px; height:50px; opacity:0; cursor:pointer; } .palette-meta { min-width:0; } .palette-meta strong { display:block; font-size:12px; } .palette-meta code { display:block; overflow:hidden; color:#777; font:10px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; text-overflow:ellipsis; white-space:nowrap; } .accent-preview { display:inline-block; width:9px; height:9px; margin-right:4px; background:var(--preview-accent); border-radius:50%; vertical-align:-1px; } .settings-actions { display:flex; justify-content:flex-end; margin-top:10px; } .settings-reset { padding:5px 8px; color:#666; background:transparent; border:1px solid rgba(0,0,0,.1); border-radius:7px; cursor:pointer; font-size:11px; } .settings-reset:hover { color:#1677ff; border-color:#91caff; } .shortcut-section { padding-bottom:7px; } .shortcut-list { display:grid; } .shortcut-row { display:grid; grid-template-columns:minmax(128px,auto) minmax(0,1fr); align-items:center; gap:10px; min-height:39px; padding:7px 0; border-bottom:1px solid rgba(0,0,0,.065); } .shortcut-row:last-child { border-bottom:0; } .shortcut-keys { display:flex; align-items:center; gap:4px; min-width:0; } .key-combo { display:inline-flex; align-items:center; gap:3px; white-space:nowrap; } .shortcut-or { color:#aaa; font-size:10px; } .shortcut-action { color:#666; font-size:12px; text-align:right; } .shortcut-section kbd { display:inline-grid; place-items:center; min-width:23px; height:23px; padding:0 6px; color:#444; background:linear-gradient(180deg,rgba(255,255,255,.96),rgba(245,245,247,.86)); border:1px solid rgba(0,0,0,.14); border-bottom-color:rgba(0,0,0,.24); border-radius:6px; box-shadow:0 1px 0 rgba(0,0,0,.12),inset 0 1px 0 #fff; font:600 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace; } .card { position:relative; margin-bottom:10px; padding:11px 11px 11px 18px; border:1px solid rgba(0,0,0,.075); border-radius:11px; background:rgba(255,255,255,.62); box-shadow:0 1px 1px rgba(0,0,0,.025); cursor:pointer; } .card::before { content:""; position:absolute; top:6px; bottom:6px; left:6px; width:4px; background:var(--card-color); border-radius:999px; } .card.active { border-color:#69b1ff; box-shadow:0 0 0 2px #e6f4ff; } .quote { margin:0 0 9px; padding-left:0; color:#555; font-size:13px; overflow-wrap:anywhere; } .note-input { width:100%; min-height:70px; resize:vertical; padding:8px; border:1px solid rgba(0,0,0,.12); border-radius:8px; outline:none; color:#333; background:rgba(255,255,255,.72); } .note-preview { margin:0 0 8px; padding:8px 9px; color:#333; background:rgba(255,255,255,.5); border-radius:8px; white-space:pre-wrap; overflow-wrap:anywhere; } .spacer { flex:1; } .link-button { padding:3px 5px; border:0; background:transparent; color:#777; cursor:pointer; font-size:12px; } .link-button:hover { color:#1677ff; } .link-button.danger:hover { color:#cf1322; } .page-group { margin-bottom:12px; overflow:hidden; border:1px solid rgba(0,0,0,.075); border-radius:12px; background:rgba(255,255,255,.58); } .page-heading { padding:10px 11px; background:rgba(255,255,255,.38); } .page-title-row { display:flex; align-items:center; gap:8px; min-width:0; } .page-title { flex:1; min-width:0; margin:0; font-size:14px; line-height:1.4; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .page-note-count { flex:none; color:#888; font-size:12px; } .toast { position:fixed; z-index:7; left:50%; bottom:28px; max-width:calc(100vw - 32px); padding:9px 14px; color:#fff; background:rgba(20,20,20,.92); border-radius:9px; opacity:0; transform:translate(-50%,10px); transition:150ms ease; pointer-events:none; } .toast.show { opacity:1; transform:translate(-50%,0); } .toast.with-action { display:flex; align-items:center; gap:14px; pointer-events:auto; } .toast-action { padding:0; border:0; color:#91caff; background:transparent; font-weight:600; cursor:pointer; } .toast-action:hover { color:#fff; } @supports ((-webkit-backdrop-filter:blur(1px)) or (backdrop-filter:blur(1px))) { .panel { background:rgba(250,250,252,.8); } } @media (prefers-color-scheme:dark) { .panel { color:#eee; background:rgba(22,22,23,.88); border-color:rgba(255,255,255,.14); box-shadow:0 20px 60px rgba(0,0,0,.42),inset 0 1px 0 rgba(255,255,255,.1); } .panel-header { background:rgba(22,22,23,.42); border-color:rgba(255,255,255,.1); } .card,.page-group { background:rgba(44,44,46,.68); border-color:rgba(255,255,255,.1); } .quote { color:#d1d1d6; } .page-heading { background:rgba(58,58,60,.42); } .search,.note-input,.icon-button,.text-button { color:#eee; background:rgba(44,44,46,.72); border-color:rgba(255,255,255,.14); } .note-preview { color:#eee; background:rgba(58,58,60,.56); } .settings-section { background:rgba(44,44,46,.68); border-color:rgba(255,255,255,.1); } .settings-value { color:#bbb; background:rgba(255,255,255,.08); } .palette-meta code { color:#aaa; } .settings-range input[type="range"] { --range-rest:rgba(255,255,255,.18); } .settings-row select,.settings-text-input { color:#eee; background:rgba(44,44,46,.8); border-color:rgba(255,255,255,.14); } .palette-setting { background:rgba(58,58,60,.46); border-color:rgba(255,255,255,.08); } .settings-reset { color:#bbb; border-color:rgba(255,255,255,.14); } .shortcut-row { border-color:rgba(255,255,255,.08); } .shortcut-action { color:#bbb; } .shortcut-or { color:#777; } .shortcut-section kbd { color:#eee; background:linear-gradient(180deg,rgba(78,78,82,.95),rgba(48,48,51,.92)); border-color:rgba(255,255,255,.16); border-bottom-color:rgba(255,255,255,.25); box-shadow:0 1px 0 rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.08); } .settings-menu { background:rgba(44,44,46,.82); border-color:rgba(255,255,255,.14); } .menu-item { color:#eee; } .menu-item:hover { background:rgba(255,255,255,.1); } .menu-state { color:#aaa; } .margin-note { color:var(--margin-color); background:transparent; } .margin-note:hover { background:transparent; filter:saturate(1.12) brightness(1.18); } @supports ((-webkit-backdrop-filter:blur(1px)) or (backdrop-filter:blur(1px))) { .panel { background:rgba(22,22,23,.8); } } } @media (max-width:959px) { .margin-connectors,.margin-notes { display:none; } } @media (max-width:520px) { .panel { top:8px; right:8px; bottom:8px; width:calc(100vw - 16px); } .palette { max-width:calc(100vw - 16px); } } `; const layer = document.createElement('div'); layer.className = 'layer'; layer.innerHTML = `
`; root.append(style, layer); (document.body || document.documentElement).appendChild(host); const palette = layer.querySelector('.palette'); DEFAULT_COLORS.forEach((color, index) => { const button = document.createElement('button'); button.type = 'button'; button.className = 'color-button'; button.dataset.colorId = String(index); button.style.background = color; button.title = `高亮颜色 ${index + 1}`; button.setAttribute('aria-label', button.title); palette.appendChild(button); }); return { host, root, layer, palette, marginNotes: layer.querySelector('.margin-notes'), marginConnectors: layer.querySelector('.margin-connectors'), panel: layer.querySelector('.panel'), notes: layer.querySelector('.notes'), count: layer.querySelector('.count'), search: layer.querySelector('.search'), toast: layer.querySelector('.toast') }; } function bindEvents() { document.addEventListener('mouseup', (event) => { if (!isUIEvent(event) && event.button === 0) window.setTimeout(showPaletteForSelection, 0); }, true); document.addEventListener('keyup', (event) => { if (isNoteShortcutActive()) return; if (!isUIEvent(event) && (event.key.startsWith('Arrow') || event.key === 'Shift')) { window.setTimeout(showPaletteForSelection, 0); } }, true); document.addEventListener('pointerdown', (event) => { if (!isUIEvent(event) && !closestHighlight(event.target)) { closeSettingsMenu(); hidePalette(); if (activeNoteId && ui.panel.classList.contains('open')) { activeNoteId = null; expandedNoteIds.clear(); renderPanel(); } } }, true); document.addEventListener('click', handleHighlightClick, true); document.addEventListener('keydown', (event) => { const noteInput = event.composedPath().find((node) => node instanceof Element && node.matches('.note-input')); if (noteInput && event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); expandedNoteIds.delete(noteInput.closest('.card')?.dataset.id); noteInput.blur(); renderPanel(); } else if (handleNoteShortcut(event)) { return; } else if ((event.metaKey || event.altKey) && event.key.toLowerCase() === 'm') { event.preventDefault(); event.stopPropagation(); if (event.altKey && event.shiftKey) openAllPages(); else togglePanel(); } else if (event.key === 'Escape') { hidePalette(); closePanel(); } }, true); window.addEventListener('scroll', handleViewportChange, true); window.addEventListener('resize', handleViewportChange); window.addEventListener('popstate', checkRoute); window.addEventListener('hashchange', checkRoute); ui.palette.addEventListener('mousedown', (event) => event.preventDefault()); ui.palette.addEventListener('click', handlePaletteClick); ui.root.querySelector('.settings-toggle').addEventListener('click', toggleSettingsMenu); ui.root.querySelector('.open-settings-button').addEventListener('click', openSettings); ui.root.querySelector('.margin-notes-toggle').addEventListener('click', toggleMarginNotes); ui.root.querySelector('.search-toggle').addEventListener('click', () => { closeSettingsMenu(); toggleSearch(); }); ui.root.querySelector('.all-pages-button').addEventListener('click', handlePanelBackNavigation); ui.root.querySelector('.export-button').addEventListener('click', () => { closeSettingsMenu(); void exportMarkdown(); }); ui.root.querySelector('.export-content-button').addEventListener('click', () => { closeSettingsMenu(); exportMarkdownWithContent(); }); ui.root.querySelector('.clear-button').addEventListener('click', () => { closeSettingsMenu(); clearPage(); }); ui.search.addEventListener('input', () => { searchText = ui.search.value.trim().toLowerCase(); renderPanel(); }); ui.notes.addEventListener('click', handlePanelClick); ui.notes.addEventListener('dblclick', handlePanelDoubleClick); ui.notes.addEventListener('input', handlePanelInput); ui.notes.addEventListener('change', handleSettingsInput); ui.marginNotes.addEventListener('click', handleMarginNoteClick); ui.layer.addEventListener('pointerdown', (event) => { if (!event.target.closest('.settings-wrap')) closeSettingsMenu(); }); ui.toast.addEventListener('click', (event) => { if (event.target.closest('[data-action="undo-delete"]')) undoLastDelete(); }); const routeTimer = window.setInterval(checkRoute, 1000); window.addEventListener('pagehide', () => { if (settingsSaveTimer) void setStored(SETTINGS_KEY, settings); window.clearInterval(routeTimer); window.clearTimeout(mutationTimer); window.clearTimeout(cardClickTimer); window.clearTimeout(highlightRestoreTimer); window.clearTimeout(settingsSaveTimer); window.cancelAnimationFrame(marginLayoutFrame); pageResizeObserver?.disconnect(); pageMutationObserver?.disconnect(); }, { once: true }); } function handleViewportChange() { hidePalette(); scheduleMarginNoteLayout(); } function observePageLayout() { if (typeof ResizeObserver === 'function') { pageResizeObserver = new ResizeObserver(scheduleMarginNoteLayout); pageResizeObserver.observe(document.documentElement); if (document.body) pageResizeObserver.observe(document.body); } if (typeof MutationObserver === 'function' && document.body) { pageMutationObserver = new MutationObserver(() => { window.clearTimeout(mutationTimer); mutationTimer = window.setTimeout(scheduleMarginNoteLayout, 100); scheduleHighlightRestore(); }); pageMutationObserver.observe(document.body, { childList: true, subtree: true, characterData: true }); } scheduleMarginNoteLayout(); } function registerScriptMenu() { if (typeof GM_registerMenuCommand !== 'function') return; GM_registerMenuCommand('打开全部网页笔记', openAllPages); GM_registerMenuCommand('打开当前网页笔记', () => { panelMode = 'page'; ui.panel.classList.add('open'); renderPanel(); }); } function isUIEvent(event) { return event.composedPath().includes(ui.host); } function isNoteShortcutActive() { return ui.panel.classList.contains('open') && panelMode === 'page' && Boolean(activeNoteId); } function handleNoteShortcut(event) { if (!isNoteShortcutActive() || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return false; if (event.composedPath().some((node) => node instanceof Element && node.matches('input,textarea,select,[contenteditable="true"]'))) return false; if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Backspace'].includes(event.key)) return false; event.preventDefault(); event.stopPropagation(); if (event.key === 'ArrowUp') moveActiveNote(-1); if (event.key === 'ArrowDown') moveActiveNote(1); if (event.key === 'ArrowLeft') changeActiveColor(-1); if (event.key === 'ArrowRight') changeActiveColor(1); if (event.key === 'Enter') toggleNoteEditor(activeNoteId); if (event.key === 'Backspace') deleteNote(activeNoteId); return true; } function getVisiblePageNotes() { return pageState.notes .filter((item) => !searchText || `${item.quote}\n${item.note}`.toLowerCase().includes(searchText)) .slice() .reverse(); } function moveActiveNote(direction) { const notes = getVisiblePageNotes(); if (!notes.length) return; const currentIndex = notes.findIndex((item) => item.id === activeNoteId); const nextIndex = currentIndex === -1 ? (direction > 0 ? 0 : notes.length - 1) : (currentIndex + direction + notes.length) % notes.length; locateOnPage(notes[nextIndex].id); } function changeActiveColor(direction) { const item = pageState.notes.find((note) => note.id === activeNoteId); if (!item) return; const currentIndex = resolveColorId(item); const nextIndex = (currentIndex + direction + settings.palette.length) % settings.palette.length; updateColor(item.id, nextIndex); } function showPaletteForSelection() { const selection = window.getSelection(); if (!selection || !selection.rangeCount || selection.isCollapsed) return; const range = selection.getRangeAt(0); if (!isUsableRange(range)) return; const snapshot = captureRange(range); if (!snapshot || !snapshot.quote.trim()) return; pendingSelection = snapshot; positionPalette(getSelectionEndRect(selection, range)); } function isUsableRange(range) { const container = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement; if (!container || !document.body.contains(container) || ui.host.contains(container)) return false; if (container.closest('input,textarea,select,[contenteditable="true"],script,style,noscript')) return false; return !getRangeSegments(range).some((item) => item.node.parentElement?.closest(`mark.${MARK_CLASS}`)); } function captureRange(range) { const index = buildTextIndex(); const segments = getRangeSegments(range, index.records); if (!segments.length) return null; const start = segments[0].absoluteStart; const last = segments.at(-1); const end = last.absoluteStart + last.end - last.start; return { range: range.cloneRange(), quote: segments.map((item) => item.node.data.slice(item.start, item.end)).join(''), prefix: index.text.slice(Math.max(0, start - CONTEXT_LENGTH), start), suffix: index.text.slice(end, end + CONTEXT_LENGTH), position: index.text.length ? start / index.text.length : 0 }; } function getSelectionEndRect(selection, range) { try { const focusRange = document.createRange(); focusRange.setStart(selection.focusNode, selection.focusOffset); focusRange.collapse(true); const focusRects = Array.from(focusRange.getClientRects()).filter((item) => item.width || item.height); const focusRect = focusRects[0] || focusRange.getBoundingClientRect(); if (focusRect && (focusRect.width || focusRect.height)) return focusRect; } catch (_) { // 某些复杂 DOM 无法为折叠选区返回坐标,下面使用选区末端兜底。 } const rects = Array.from(range.getClientRects()).filter((item) => item.width || item.height); return rects.at(-1) || range.getBoundingClientRect(); } function positionPalette(rect) { ui.palette.classList.add('visible'); ui.palette.style.visibility = 'hidden'; const size = ui.palette.getBoundingClientRect(); const anchorX = rect.right || rect.left; let left = anchorX + 9; let top = rect.top - size.height - 9; if (left + size.width > innerWidth - 8) left = anchorX - size.width - 9; if (top < 8) top = rect.bottom + 9; ui.palette.style.left = `${Math.max(8, Math.min(left, innerWidth - size.width - 8))}px`; ui.palette.style.top = `${Math.max(8, Math.min(top, innerHeight - size.height - 8))}px`; ui.palette.style.visibility = ''; } function hidePalette() { ui.palette.classList.remove('visible'); pendingSelection = null; } function handlePaletteClick(event) { const colorButton = event.target.closest('[data-color-id]'); if (!colorButton) return; addHighlight(Number(colorButton.dataset.colorId)); } function addHighlight(colorId) { if (!pendingSelection || !document.contains(pendingSelection.range.commonAncestorContainer)) { hidePalette(); return null; } const safeColorId = Number.isInteger(colorId) && colorId >= 0 && colorId < settings.palette.length ? colorId : 0; const now = new Date().toISOString(); const note = { id: globalThis.crypto?.randomUUID?.() || `mw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`, quote: pendingSelection.quote, prefix: pendingSelection.prefix, suffix: pendingSelection.suffix, position: pendingSelection.position, colorId: safeColorId, color: settings.palette[safeColorId], note: '', createdAt: now, updatedAt: now }; if (!wrapRange(pendingSelection.range, note)) { toast('这段内容暂时无法标记'); hidePalette(); return null; } pageState.notes.push(note); scheduleSave(); renderPanel(); window.getSelection()?.removeAllRanges(); hidePalette(); toast('高亮已保存'); return note; } function buildTextIndex() { const records = []; let text = ''; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { acceptNode(node) { const parent = node.parentElement; if (!node.data || !parent || parent.closest(`#${APP_ID},script,style,noscript,textarea,input,select,option,canvas,svg,[contenteditable="true"]`)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); let node; while ((node = walker.nextNode())) { records.push({ node, start: text.length, end: text.length + node.data.length }); text += node.data; } return { text, records }; } function getRangeSegments(range, records = buildTextIndex().records) { const segments = []; for (const record of records) { let intersects; try { intersects = range.intersectsNode(record.node); } catch (_) { continue; } if (!intersects) continue; const start = record.node === range.startContainer ? range.startOffset : 0; const end = record.node === range.endContainer ? range.endOffset : record.node.data.length; if (end > start) segments.push({ node: record.node, start, end, absoluteStart: record.start + start }); } return segments; } function wrapRange(range, note) { const segments = getRangeSegments(range); if (!segments.length || segments.some((item) => item.node.parentElement?.closest(`mark.${MARK_CLASS}`))) return false; for (const item of segments.reverse()) { let node = item.node; if (item.end < node.length) node.splitText(item.end); if (item.start > 0) node = node.splitText(item.start); if (!node.data) continue; const mark = document.createElement('mark'); mark.className = MARK_CLASS; mark.dataset.markwebId = note.id; const color = noteHighlightColor(note); mark.style.setProperty('--markweb-color', color); mark.style.setProperty('--markweb-text-color', contrastTextColor(color)); mark.tabIndex = 0; mark.title = note.note || '点击编辑批注'; node.parentNode.insertBefore(mark, node); mark.appendChild(node); } return true; } function restoreHighlights(render = true) { let restored = false; for (const note of pageState.notes) { if (findMarks(note.id).length) continue; const range = locateNote(note); if (range && wrapRange(range, note)) restored = true; } if (render) renderPanel(); else if (restored) scheduleMarginNoteLayout(); return restored; } function scheduleHighlightRestore(delay = 350) { window.clearTimeout(highlightRestoreTimer); if (!pageState.notes.length) return; const requestedKey = activePageKey; highlightRestoreTimer = window.setTimeout(() => { if (requestedKey !== activePageKey || !pageState.notes.length) return; const restoredIds = new Set(Array.from(document.querySelectorAll(`mark.${MARK_CLASS}`), (mark) => mark.dataset.markwebId)); if (pageState.notes.some((note) => !restoredIds.has(note.id))) restoreHighlights(false); }, delay); } function locateNote(note) { const index = buildTextIndex(); const candidates = []; let from = 0; while (from <= index.text.length) { const found = index.text.indexOf(note.quote, from); if (found < 0) break; candidates.push(found); from = found + Math.max(1, note.quote.length); if (candidates.length >= 100) break; } const expected = Math.round(note.position * index.text.length); candidates.sort((a, b) => score(b, note, index.text, expected) - score(a, note, index.text, expected)); for (const start of candidates) { const range = rangeFromOffsets(index.records, start, start + note.quote.length); if (range && !getRangeSegments(range, index.records).some((item) => item.node.parentElement?.closest(`mark.${MARK_CLASS}`))) return range; } return null; } function score(start, note, text, expected) { let value = -Math.abs(start - expected) / Math.max(1, text.length); if (note.prefix && text.slice(Math.max(0, start - note.prefix.length), start) === note.prefix) value += 4; const end = start + note.quote.length; if (note.suffix && text.slice(end, end + note.suffix.length) === note.suffix) value += 4; return value; } function rangeFromOffsets(records, start, end) { let first; let last; for (const record of records) { if (!first && start >= record.start && start < record.end) first = record; if (end > record.start && end <= record.end) { last = record; break; } } if (!first || !last) return null; const range = document.createRange(); range.setStart(first.node, start - first.start); range.setEnd(last.node, end - last.start); return range; } function closestHighlight(target) { return target instanceof Element ? target.closest(`mark.${MARK_CLASS}`) : target?.parentElement?.closest(`mark.${MARK_CLASS}`); } function handleHighlightClick(event) { if (isUIEvent(event)) return; const mark = closestHighlight(event.target); if (!mark) return; event.preventDefault(); openNote(mark.dataset.markwebId); } function findMarks(id) { return Array.from(document.querySelectorAll(`mark.${MARK_CLASS}`)).filter((mark) => mark.dataset.markwebId === id); } function scheduleMarginNoteLayout() { if (marginLayoutFrame) return; marginLayoutFrame = window.requestAnimationFrame(() => { marginLayoutFrame = 0; renderMarginNotes(); }); } function clearMarginNotes() { ui.marginNotes.replaceChildren(); ui.marginConnectors.replaceChildren(); } function visibleHighlightAnchor(id) { const candidates = findMarks(id) .flatMap((mark) => Array.from(mark.getClientRects()).map((rect) => ({ mark, rect }))) .filter(({ rect }) => rect.width > 0 && rect.height > 0 && rect.bottom > 8 && rect.top < innerHeight - 8 && rect.right > 0 && rect.left < innerWidth); if (!candidates.length) return null; const selected = candidates.sort((a, b) => { const aCenter = a.rect.top + a.rect.height / 2; const bCenter = b.rect.top + b.rect.height / 2; return Math.abs(aCenter - innerHeight / 2) - Math.abs(bCenter - innerHeight / 2); })[0]; return { rect: selected.rect, contentRect: closestContentRect(selected.mark, selected.rect) }; } function closestContentRect(mark, fallbackRect) { const blockDisplays = new Set(['block', 'list-item', 'flow-root', 'table-cell', 'flex', 'grid']); let element = mark.parentElement; while (element && element !== document.body && element !== document.documentElement) { const rect = element.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0 && blockDisplays.has(getComputedStyle(element).display)) { return rect; } element = element.parentElement; } return fallbackRect; } function renderMarginNotes() { clearMarginNotes(); if (!settings.marginNotes.enabled || innerWidth < 960 || innerHeight < 360) return; const panelBoundary = ui.panel.classList.contains('open') ? innerWidth - ui.panel.offsetWidth - 26 : innerWidth - 12; const entries = []; const fragment = document.createDocumentFragment(); for (const item of pageState.notes) { const text = item.note.trim(); if (!text) continue; const anchor = visibleHighlightAnchor(item.id); if (!anchor) continue; const { rect, contentRect } = anchor; const leftSpace = contentRect.left - 12; const rightSpace = panelBoundary - contentRect.right; const side = settings.marginNotes.side === 'auto' ? (rightSpace > leftSpace ? 'right' : 'left') : settings.marginNotes.side; const available = side === 'left' ? leftSpace : rightSpace; const noteGap = settings.marginNotes.distance; if (available < noteGap + 142) continue; const width = Math.min(220, Math.floor(available - noteGap)); const left = side === 'left' ? contentRect.left - noteGap - width : contentRect.right + noteGap; const element = document.createElement('button'); element.type = 'button'; element.className = `margin-note${item.id === activeNoteId ? ' active' : ''}`; element.dataset.id = item.id; element.style.setProperty('--margin-color', cardColor(item)); element.style.fontSize = `${settings.marginNotes.fontSize}px`; element.style.fontFamily = marginNoteFontStack(); element.style.width = `${width}px`; element.style.left = `${left}px`; element.style.top = '0'; element.style.visibility = 'hidden'; element.textContent = text; element.title = `${text}\n\n点击打开对应笔记`; element.setAttribute('aria-label', `备注:${shorten(text, 80)}`); fragment.appendChild(element); entries.push({ item, element, side, left, width, anchorX: side === 'left' ? rect.left - 2 : rect.right + 2, anchorY: rect.top + rect.height / 2, desiredY: rect.top + rect.height / 2 }); } ui.marginNotes.appendChild(fragment); for (const entry of entries) entry.height = entry.element.getBoundingClientRect().height; const visibleEntries = [ ...packMarginColumn(entries.filter((entry) => entry.side === 'left')), ...packMarginColumn(entries.filter((entry) => entry.side === 'right')) ]; ui.marginConnectors.setAttribute('viewBox', `0 0 ${innerWidth} ${innerHeight}`); ui.marginConnectors.setAttribute('width', String(innerWidth)); ui.marginConnectors.setAttribute('height', String(innerHeight)); const connectorFragment = document.createDocumentFragment(); for (const entry of visibleEntries) { entry.element.style.top = `${entry.top}px`; entry.element.style.visibility = ''; const noteX = entry.side === 'left' ? entry.left + entry.width + 3 : entry.left - 3; const noteY = entry.top + entry.height / 2; const middleX = (entry.anchorX + noteX) / 2; const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', `M ${entry.anchorX} ${entry.anchorY} C ${middleX} ${entry.anchorY}, ${middleX} ${noteY}, ${noteX} ${noteY}`); path.setAttribute('fill', 'none'); path.setAttribute('stroke', cardColor(entry.item)); path.setAttribute('stroke-width', entry.item.id === activeNoteId ? '2.2' : '1.6'); path.setAttribute('stroke-dasharray', '4 5'); path.setAttribute('stroke-linecap', 'round'); path.setAttribute('opacity', entry.item.id === activeNoteId ? '1' : '.92'); connectorFragment.appendChild(path); } ui.marginConnectors.appendChild(connectorFragment); } function packMarginColumn(entries) { if (!entries.length) return []; const edge = 12; const gap = 10; const availableHeight = innerHeight - edge * 2; let usedHeight = 0; const selected = entries .slice() .sort((a, b) => Math.abs(a.desiredY - innerHeight / 2) - Math.abs(b.desiredY - innerHeight / 2)) .filter((entry) => { const nextHeight = usedHeight + entry.height + (usedHeight ? gap : 0); if (nextHeight > availableHeight) { entry.element.remove(); return false; } usedHeight = nextHeight; return true; }) .sort((a, b) => a.desiredY - b.desiredY); let cursor = edge; for (const entry of selected) { const idealTop = Math.max(edge, Math.min(entry.desiredY - entry.height / 2, innerHeight - edge - entry.height)); entry.top = Math.max(idealTop, cursor); cursor = entry.top + entry.height + gap; } cursor = innerHeight - edge; for (let index = selected.length - 1; index >= 0; index -= 1) { const entry = selected[index]; entry.top = Math.min(entry.top, cursor - entry.height); cursor = entry.top - gap; } return selected; } function handleMarginNoteClick(event) { const marginNote = event.target.closest('.margin-note'); if (!marginNote) return; event.preventDefault(); event.stopPropagation(); openNote(marginNote.dataset.id); } function openNote(id) { panelMode = 'page'; activeNoteId = id; ui.panel.classList.add('open'); renderPanel(); window.setTimeout(() => { const card = Array.from(ui.notes.querySelectorAll('.card')).find((item) => item.dataset.id === id); card?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 0); } function togglePanel() { ui.panel.classList.toggle('open'); closeSettingsMenu(); if (ui.panel.classList.contains('open')) renderPanel(); else { activeNoteId = null; expandedNoteIds.clear(); resetSearch(); } scheduleMarginNoteLayout(); } function closePanel() { ui.panel.classList.remove('open'); closeSettingsMenu(); activeNoteId = null; expandedNoteIds.clear(); resetSearch(); scheduleMarginNoteLayout(); } function toggleSettingsMenu() { ui.root.querySelector('.settings-menu').classList.toggle('open'); } function syncPaletteButtons() { ui.palette.querySelectorAll('[data-color-id]').forEach((button) => { const index = Number(button.dataset.colorId); const color = settings.palette[index] || DEFAULT_COLORS[index]; button.style.background = color; button.title = `高亮颜色 ${index + 1}:${color.toUpperCase()}`; button.setAttribute('aria-label', button.title); }); } function syncMarginNotesToggle() { const button = ui.root.querySelector('.margin-notes-toggle'); if (!button) return; button.setAttribute('aria-pressed', String(settings.marginNotes.enabled)); button.querySelector('.menu-state').textContent = settings.marginNotes.enabled ? '开' : '关'; } function toggleMarginNotes() { settings.marginNotes.enabled = !settings.marginNotes.enabled; syncMarginNotesToggle(); closeSettingsMenu(); scheduleSettingsSave(); scheduleMarginNoteLayout(); if (panelMode === 'settings') renderSettingsPage(); toast(settings.marginNotes.enabled ? '已显示页边备注' : '已隐藏页边备注'); } function closeSettingsMenu() { ui.root.querySelector('.settings-menu').classList.remove('open'); } function toggleSearch() { if (ui.search.classList.contains('open')) { resetSearch(); renderPanel(); return; } ui.search.classList.add('open'); window.setTimeout(() => ui.search.focus(), 0); } function resetSearch() { searchText = ''; ui.search.value = ''; ui.search.classList.remove('open'); } function openSettings() { settingsReturnMode = panelMode === 'all' ? 'all' : 'page'; panelMode = 'settings'; activeNoteId = null; expandedNoteIds.clear(); resetSearch(); closeSettingsMenu(); renderPanel(); } function handlePanelBackNavigation() { closeSettingsMenu(); if (panelMode === 'settings') { panelMode = settingsReturnMode; renderPanel(); if (panelMode === 'all' && !allPages.length) void loadAllPages(); return; } void toggleAllPages(); } function renderPanel() { scheduleMarginNoteLayout(); const allButton = ui.root.querySelector('.all-pages-button'); const settingsWrap = ui.root.querySelector('.settings-wrap'); const exportButton = ui.root.querySelector('.export-button'); const exportContentButton = ui.root.querySelector('.export-content-button'); const clearButton = ui.root.querySelector('.clear-button'); const isAll = panelMode === 'all'; const isSettings = panelMode === 'settings'; ui.search.placeholder = isAll ? '搜索网页标题…' : '搜索原文或备注…'; allButton.innerHTML = panelIcon(isAll || isSettings ? 'back' : 'home'); allButton.title = isSettings ? '返回' : (isAll ? '返回本页' : '全部网页'); allButton.setAttribute('aria-label', allButton.title); settingsWrap.style.visibility = isSettings ? 'hidden' : ''; exportButton.title = isAll ? '导出全部' : '导出笔记'; exportButton.setAttribute('aria-label', exportButton.title); exportButton.querySelector('span').textContent = exportButton.title; exportContentButton.style.display = isAll || isSettings ? 'none' : ''; clearButton.style.display = isAll || isSettings ? 'none' : ''; if (isSettings) { ui.count.textContent = '设置'; ui.search.classList.remove('open'); renderSettingsPage(); return; } if (isAll) { renderAllPages(); return; } ui.count.textContent = `${pageState.notes.length} 条`; if (ui.search.value !== searchText) ui.search.value = searchText; const notes = getVisiblePageNotes(); if (!notes.length) { ui.notes.innerHTML = `${escapeHTML(shorten(item.quote, 240))}${editorOpen ? `` : (item.note.trim() ? `
${escapeHTML(item.note)}
` : '')}