AO3 CtnRead

在AO3作品列表标题旁显示📑图标,点击📑可直接打开文章恢复到上次阅读的位置,支持导出/导入阅读进度

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

Advertisement:

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

Advertisement:

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name        AO3 CtnRead
// @description 在AO3作品列表标题旁显示📑图标,点击📑可直接打开文章恢复到上次阅读的位置,支持导出/导入阅读进度
// @namespace   https://greasyfork.org/users/1600410
// @author      Forever A
// @version     2.9.3
// @license     MIT
// @icon         data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text x='10' y='80' font-size='70'>📑</text></svg>
// @icon64       data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='64' height='64' viewBox='0 0 100 100'><text x='10' y='80' font-size='70'>📑</text></svg>
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_registerMenuCommand
// @grant       GM_unregisterMenuCommand
// @require     https://ajax.googleapis.com/ajax/libs/jquery/1.9.0/jquery.min.js
// @include     http://*archiveofourown.org/*
// @include     https://*archiveofourown.org/*
// ==/UserScript==

(function($) {
    'use strict';

    if (typeof Storage === 'undefined') {
        return;
    }

    var SETTINGS_KEY = 'kh_continue_reading_settings';
    var defaultSettings = { showFloatingIcon: true };
    var settings = getSettings();
    var menuCommandId = null;

    function getSettings() {
        try {
            var saved = GM_getValue(SETTINGS_KEY);
            if (saved) {
                return JSON.parse(saved);
            }
        } catch (e) {}
        return JSON.parse(JSON.stringify(defaultSettings));
    }

    function saveSettings(s) {
        GM_setValue(SETTINGS_KEY, JSON.stringify(s));
    }

    function getAllProgressData() {
        var data = {};
        for (var i = 0; i < localStorage.length; i++) {
            var key = localStorage.key(i);
            if (key && key.startsWith('kh_pos_')) {
                data[key] = localStorage.getItem(key);
            }
        }
        return data;
    }

    function importProgressData(data) {
        var count = 0;
        for (var key in data) {
            if (data.hasOwnProperty(key) && key.startsWith('kh_pos_')) {
                try {
                    JSON.parse(data[key]);
                    localStorage.setItem(key, data[key]);
                    count++;
                } catch (e) {}
            }
        }
        return count;
    }

    function clearOldData(days) {
        var now = Date.now();
        var cutoff = now - days * 24 * 60 * 60 * 1000;
        var keys = [];
        for (var i = 0; i < localStorage.length; i++) {
            var key = localStorage.key(i);
            if (key && key.startsWith('kh_pos_')) {
                try {
                    var data = JSON.parse(localStorage.getItem(key));
                    if (data && data.updated && data.updated < cutoff) {
                        keys.push(key);
                    }
                } catch (e) {}
            }
        }
        keys.forEach(function(k) {
            localStorage.removeItem(k);
        });
        return keys.length;
    }

    function countOldData(days) {
        var now = Date.now();
        var cutoff = now - days * 24 * 60 * 60 * 1000;
        var count = 0;
        for (var i = 0; i < localStorage.length; i++) {
            var key = localStorage.key(i);
            if (key && key.startsWith('kh_pos_')) {
                try {
                    var data = JSON.parse(localStorage.getItem(key));
                    if (data && data.updated && data.updated < cutoff) {
                        count++;
                    }
                } catch (e) {}
            }
        }
        return count;
    }

    function createFloatingIcon() {
        if ($('#kh-floating-icon').length || !settings.showFloatingIcon) {
            return;
        }

        var iconW = 40;
        var snapOffset = Math.round(iconW * 0.6);
        var edgeMargin = 12;
        var isSnapped = false;
        var isHovering = false;
        var isDragging = false;
        var dragThreshold = 8;
        var snapData = null;
        var hoverTimer = null;

        var icon = $('<div id="kh-floating-icon" style="position:fixed;z-index:2147483647;cursor:pointer;font-size:28px;opacity:0.5;transition:opacity 0.3s, left 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), top 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);user-select:none;width:' + iconW + 'px;height:' + iconW + 'px;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,0.85);border-radius:50%;box-shadow:0 2px 10px rgba(0,0,0,0.15);-webkit-touch-callout:none;-webkit-user-select:none;touch-action:none;pointer-events:auto;will-change:transform;">📑</div>');

        var savedPos = localStorage.getItem('kh_icon_position');
        var currentX;
        var currentY;

        if (savedPos) {
            try {
                var pos = JSON.parse(savedPos);
                currentX = pos.x;
                currentY = pos.y;
                icon.css({
                    left: currentX + 'px',
                    top: currentY + 'px',
                    transform: 'translateX(0px) translateY(0px)'
                });
            } catch (e) {
                currentX = window.innerWidth - iconW - edgeMargin;
                currentY = window.innerHeight - iconW - edgeMargin;
                icon.css({
                    left: currentX + 'px',
                    top: currentY + 'px',
                    transform: 'translateX(0px) translateY(0px)'
                });
            }
        } else {
            currentX = window.innerWidth - iconW - edgeMargin;
            currentY = window.innerHeight - iconW - edgeMargin;
            icon.css({
                left: currentX + 'px',
                top: currentY + 'px',
                transform: 'translateX(0px) translateY(0px)'
            });
        }

        function getIconPos() {
            var left = parseInt(icon.css('left'), 10);
            var top = parseInt(icon.css('top'), 10);
            if (isNaN(left)) {
                left = currentX || 0;
            }
            if (isNaN(top)) {
                top = currentY || 0;
            }
            return { x: left, y: top };
        }

        function savePosition(x, y) {
            localStorage.setItem('kh_icon_position', JSON.stringify({ x: x, y: y }));
            currentX = x;
            currentY = y;
        }

        function updateOpacity() {
            if (isDragging) {
                return;
            }
            var rect = icon[0].getBoundingClientRect();
            var isNearEdge = rect.left < 30 || rect.right > window.innerWidth - 30 ||
                rect.top < 30 || rect.bottom > window.innerHeight - 30;
            icon.css('opacity', isNearEdge ? '0.5' : '1');
        }

        function setSnapState(snapped, animate) {
            var left = parseInt(icon.css('left'), 10);
            var top = parseInt(icon.css('top'), 10);
            if (isNaN(left)) {
                left = currentX || 0;
            }
            if (isNaN(top)) {
                top = currentY || 0;
            }

            var duration = animate ? '0.25s' : '0s';
            var easing = animate ? 'cubic-bezier(0.34, 1.56, 0.64, 1)' : 'ease';

            var atRight = left + iconW > window.innerWidth - 30;
            var atLeft = left < 30;
            var atTop = top < 30;
            var atBottom = top + iconW > window.innerHeight - 30;

            if (isHovering || isDragging) {
                var targetLeft = left;
                var targetTop = top;
                if (isSnapped && snapData) {
                    if (snapData.direction === 'right') {
                        targetLeft = window.innerWidth - iconW - edgeMargin;
                    } else if (snapData.direction === 'left') {
                        targetLeft = edgeMargin;
                    } else if (snapData.direction === 'top') {
                        targetTop = edgeMargin;
                    } else if (snapData.direction === 'bottom') {
                        targetTop = window.innerHeight - iconW - edgeMargin;
                    }
                }
                icon.css({
                    left: targetLeft + 'px',
                    top: targetTop + 'px',
                    transform: 'translateX(0px) translateY(0px)',
                    transition: 'opacity 0.3s, left ' + duration + ' ' + easing + ', top ' + duration + ' ' + easing + ', transform ' + duration + ' ' + easing
                });
                isSnapped = false;
                snapData = null;
                updateOpacity();
                return;
            }

            var tx = 0;
            var ty = 0;
            var snapX = left;
            var snapY = top;
            var shouldSnap = false;
            var direction = null;

            if (atRight) {
                tx = (window.innerWidth - iconW + snapOffset) - left;
                snapX = left;
                shouldSnap = true;
                direction = 'right';
            } else if (atLeft) {
                tx = (-iconW + snapOffset) - left;
                snapX = left;
                shouldSnap = true;
                direction = 'left';
            } else if (atTop) {
                ty = (-iconW + snapOffset) - top;
                snapY = top;
                shouldSnap = true;
                direction = 'top';
            } else if (atBottom) {
                ty = (window.innerHeight - iconW + snapOffset) - top;
                snapY = top;
                shouldSnap = true;
                direction = 'bottom';
            }

            if (shouldSnap && snapped) {
                icon.css({
                    left: snapX + 'px',
                    top: snapY + 'px',
                    transform: 'translateX(' + tx + 'px) translateY(' + ty + 'px)',
                    transition: 'opacity 0.3s, left ' + duration + ' ' + easing + ', top ' + duration + ' ' + easing + ', transform ' + duration + ' ' + easing
                });
                isSnapped = true;
                snapData = { direction: direction, tx: tx, ty: ty };
            } else {
                icon.css({
                    transform: 'translateX(0px) translateY(0px)',
                    transition: 'opacity 0.3s, left ' + duration + ' ' + easing + ', top ' + duration + ' ' + easing + ', transform ' + duration + ' ' + easing
                });
                isSnapped = false;
                snapData = null;
            }
            updateOpacity();
        }

        function checkAndSnap(animate) {
            if (isDragging) {
                return;
            }
            setSnapState(true, animate);
        }

        function expandIcon() {
            var pos = getIconPos();
            var targetLeft = pos.x;
            var targetTop = pos.y;
            if (isSnapped && snapData) {
                if (snapData.direction === 'right') {
                    targetLeft = window.innerWidth - iconW - edgeMargin;
                } else if (snapData.direction === 'left') {
                    targetLeft = edgeMargin;
                } else if (snapData.direction === 'top') {
                    targetTop = edgeMargin;
                } else if (snapData.direction === 'bottom') {
                    targetTop = window.innerHeight - iconW - edgeMargin;
                }
            }
            icon.css({
                left: targetLeft + 'px',
                top: targetTop + 'px',
                transform: 'translateX(0px) translateY(0px)',
                transition: 'opacity 0.3s, left 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), top 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)',
                opacity: '1'
            });
            isSnapped = false;
            snapData = null;
        }

        function hidePanel() {
            var panel = $('#kh-panel');
            if (!panel.length) {
                return;
            }
            panel.css({
                opacity: '0',
                transform: 'scale(0.9) translateY(8px)',
                transition: 'opacity 0.15s ease-out, transform 0.15s ease-out'
            });
            setTimeout(function() {
                panel.hide();
                panel.css({
                    opacity: '1',
                    transform: 'scale(1) translateY(0px)',
                    transition: 'none'
                });
            }, 180);
        }

        function showPanelWithAnimation() {
            var panel = $('#kh-panel');
            if (panel.is(':visible')) {
                hidePanel();
                return;
            }

            expandIcon();

            setTimeout(function() {
                var iconRect = icon[0].getBoundingClientRect();
                var panelWidth = 140;
                var panelHeight = 185;
                var margin = 12;

                var x = iconRect.right + 8;
                var y = iconRect.top + (iconRect.height - panelHeight) / 2;

                if (x + panelWidth > window.innerWidth - margin) {
                    x = iconRect.left - panelWidth - 8;
                }

                if (x < margin) {
                    x = iconRect.left + (iconRect.width - panelWidth) / 2;
                    y = iconRect.bottom + 8;
                    if (y + panelHeight > window.innerHeight - margin) {
                        y = iconRect.top - panelHeight - 8;
                    }
                }

                x = Math.max(margin, Math.min(window.innerWidth - panelWidth - margin, x));
                y = Math.max(margin, Math.min(window.innerHeight - panelHeight - margin, y));

                panel.css({
                    left: x + 'px',
                    top: y + 'px',
                    opacity: '0',
                    transform: 'scale(0.9) translateY(8px)',
                    transition: 'none'
                });
                panel.show();

                panel[0].offsetHeight;

                panel.css({
                    opacity: '1',
                    transform: 'scale(1) translateY(0px)',
                    transition: 'opacity 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)'
                });
            }, 100);
        }

        icon.on('mouseenter', function() {
            clearTimeout(hoverTimer);
            isHovering = true;
            expandIcon();
        });

        icon.on('mouseleave', function() {
            clearTimeout(hoverTimer);
            hoverTimer = setTimeout(function() {
                isHovering = false;
                if (!isDragging) {
                    checkAndSnap(true);
                }
            }, 150);
        });

        icon.on('mousedown', function(e) {
            if (e.button !== 0) {
                return;
            }
            var pos = getIconPos();
            var dragData = {
                active: true,
                startX: e.clientX,
                startY: e.clientY,
                offsetX: e.clientX - pos.x,
                offsetY: e.clientY - pos.y,
                moved: false,
                click: true
            };

            clearTimeout(hoverTimer);

            icon.css({
                transform: 'translateX(0px) translateY(0px)',
                transition: 'none',
                opacity: '1',
                cursor: 'grabbing'
            });
            isSnapped = false;
            snapData = null;
            isDragging = true;

            function onMouseMove(ev) {
                var dx = ev.clientX - dragData.startX;
                var dy = ev.clientY - dragData.startY;

                if (!dragData.moved) {
                    if (Math.abs(dx) > dragThreshold || Math.abs(dy) > dragThreshold) {
                        dragData.moved = true;
                        dragData.click = false;
                    }
                }

                if (dragData.moved) {
                    var x = ev.clientX - dragData.offsetX;
                    var y = ev.clientY - dragData.offsetY;
                    x = Math.max(-iconW * 0.5, Math.min(window.innerWidth - iconW * 0.5, x));
                    y = Math.max(-iconW * 0.5, Math.min(window.innerHeight - iconW * 0.5, y));
                    icon.css({
                        left: x + 'px',
                        top: y + 'px',
                        transform: 'translateX(0px) translateY(0px)',
                        transition: 'none'
                    });
                }
            }

            function onMouseUp() {
                $(document).off('mousemove', onMouseMove);
                $(document).off('mouseup', onMouseUp);

                if (dragData.moved) {
                    var pos2 = getIconPos();
                    savePosition(pos2.x, pos2.y);
                    setTimeout(function() {
                        isDragging = false;
                        if (!isHovering) {
                            checkAndSnap(true);
                        }
                        icon.css('cursor', 'pointer');
                        icon.css('transition', 'opacity 0.3s, left 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), top 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)');
                    }, 100);
                } else if (dragData.click) {
                    showPanelWithAnimation();
                    isDragging = false;
                    icon.css('cursor', 'pointer');
                } else {
                    isDragging = false;
                    icon.css('cursor', 'pointer');
                }
            }

            $(document).on('mousemove', onMouseMove);
            $(document).on('mouseup', onMouseUp);
            e.preventDefault();
        });

        icon[0].addEventListener('touchstart', function(e) {
            var touch = e.touches[0];
            if (!touch) {
                return;
            }
            var pos = getIconPos();
            var dragData = {
                active: true,
                startX: touch.clientX,
                startY: touch.clientY,
                offsetX: touch.clientX - pos.x,
                offsetY: touch.clientY - pos.y,
                moved: false,
                click: true
            };

            clearTimeout(hoverTimer);

            icon.css({
                transform: 'translateX(0px) translateY(0px)',
                transition: 'none',
                opacity: '1',
                cursor: 'grabbing'
            });
            isSnapped = false;
            snapData = null;
            isDragging = true;

            function onTouchMove(ev) {
                var t = ev.touches[0];
                if (!t) {
                    return;
                }
                var dx = t.clientX - dragData.startX;
                var dy = t.clientY - dragData.startY;

                if (!dragData.moved) {
                    if (Math.abs(dx) > dragThreshold || Math.abs(dy) > dragThreshold) {
                        dragData.moved = true;
                        dragData.click = false;
                    }
                }

                if (dragData.moved) {
                    var x = t.clientX - dragData.offsetX;
                    var y = t.clientY - dragData.offsetY;
                    x = Math.max(-iconW * 0.5, Math.min(window.innerWidth - iconW * 0.5, x));
                    y = Math.max(-iconW * 0.5, Math.min(window.innerHeight - iconW * 0.5, y));
                    icon.css({
                        left: x + 'px',
                        top: y + 'px',
                        transform: 'translateX(0px) translateY(0px)',
                        transition: 'none'
                    });
                }
                ev.preventDefault();
            }

            function onTouchEnd() {
                document.removeEventListener('touchmove', onTouchMove);
                document.removeEventListener('touchend', onTouchEnd);
                document.removeEventListener('touchcancel', onTouchEnd);

                if (dragData.moved) {
                    var pos2 = getIconPos();
                    savePosition(pos2.x, pos2.y);
                    setTimeout(function() {
                        isDragging = false;
                        if (!isHovering) {
                            checkAndSnap(true);
                        }
                        icon.css('cursor', 'pointer');
                        icon.css('transition', 'opacity 0.3s, left 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), top 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)');
                    }, 100);
                } else if (dragData.click) {
                    showPanelWithAnimation();
                    isDragging = false;
                    icon.css('cursor', 'pointer');
                } else {
                    isDragging = false;
                    icon.css('cursor', 'pointer');
                }
            }

            document.addEventListener('touchmove', onTouchMove, { passive: false });
            document.addEventListener('touchend', onTouchEnd, { passive: true });
            document.addEventListener('touchcancel', onTouchEnd, { passive: true });
        }, { passive: true });

        icon.on('click', function(e) {
            if (dragData && dragData.moved) {
                e.stopPropagation();
                e.preventDefault();
            }
        });

        $(window).on('resize', function() {
            if (icon.length && icon.is(':visible') && !isDragging) {
                setTimeout(function() {
                    checkAndSnap(false);
                }, 100);
            }
        });

        setTimeout(function() {
            checkAndSnap(false);
        }, 300);
        $('body').append(icon);
    }

    function toggleFloatingIcon(show) {
        settings.showFloatingIcon = show;
        saveSettings(settings);
        if (show) {
            if (!$('#kh-floating-icon').length) {
                createFloatingIcon();
            } else {
                $('#kh-floating-icon').show();
            }
        } else {
            $('#kh-floating-icon').hide();
            $('#kh-panel').hide();
        }
        updateMenuCommand();
    }

    function updateMenuCommand() {
        if (menuCommandId !== null) {
            try {
                GM_unregisterMenuCommand(menuCommandId);
            } catch (e) {}
        }
        var label = settings.showFloatingIcon ? '🙈 隐藏悬浮按钮' : '👀 显示悬浮按钮';
        menuCommandId = GM_registerMenuCommand(label, function() {
            toggleFloatingIcon(!settings.showFloatingIcon);
        });
    }

    function createPanel() {
        if ($('#kh-panel').length) {
            return;
        }

        var panel = $('<div id="kh-panel" style="display:none;position:fixed;z-index:2147483647;background:#fff;border:1px solid #ddd;border-radius:8px;padding:10px 12px;min-width:100px;max-width:160px;box-shadow:0 4px 20px rgba(0,0,0,0.15);font-size:12px;color:#333;transform-origin:center center;backface-visibility:hidden;will-change:transform,opacity;"></div>');

        panel.on('click', function(e) {
            e.stopPropagation();
        });

        var btnArea = $('<div style="display:flex;flex-direction:column;gap:4px;align-items:center;"></div>');

        var exportBtn = $('<button style="width:80px;background:#28a745;color:#fff;border:0;border-radius:4px;padding:5px 10px;cursor:pointer;font-size:12px;text-align:center;touch-action:manipulation;">📤 导出</button>')
            .click(function(e) {
                e.stopPropagation();
                var data = getAllProgressData();
                var json = JSON.stringify(data, null, 2);
                var blob = new Blob([json], { type: 'application/json' });
                var url = URL.createObjectURL(blob);
                var a = document.createElement('a');
                a.href = url;
                a.download = 'ao3_reading_progress_' + new Date().toISOString().slice(0, 10) + '.json';
                document.body.appendChild(a);
                a.click();
                document.body.removeChild(a);
                URL.revokeObjectURL(url);
                hidePanel();
            });

        var importBtn = $('<button style="width:80px;background:#17a2b8;color:#fff;border:0;border-radius:4px;padding:5px 10px;cursor:pointer;font-size:12px;text-align:center;touch-action:manipulation;">📥 导入</button>')
            .click(function(e) {
                e.stopPropagation();
                var input = $('<input type="file" accept=".json" style="display:none;">');
                input.on('change', function() {
                    var file = this[0].files[0];
                    if (!file) {
                        return;
                    }
                    var reader = new FileReader();
                    reader.onload = function(ev) {
                        try {
                            var data = JSON.parse(ev.target.result);
                            var count = importProgressData(data);
                            alert('成功导入 ' + count + ' 个作品的阅读进度!\n页面即将刷新。');
                            location.reload();
                        } catch (err) {
                            alert('导入失败:文件格式错误\n请确保导入的是有效的 JSON 文件。');
                        }
                    };
                    reader.readAsText(file);
                    input.remove();
                });
                input.appendTo('body');
                input.click();
                hidePanel();
            });

        var clearLabel = $('<div style="font-size:10px;color:#999;margin-top:3px;text-align:center;">清除超过 N 天未更新的记录</div>');
        var clearBtnGroup = $('<div style="display:flex;gap:4px;margin-bottom:2px;"></div>');

        function createClearBtn(days, label, color, bgColor) {
            return $('<button style="background:' + bgColor + ';color:' + color + ';border:0;border-radius:3px;padding:3px 8px;cursor:pointer;font-size:10px;text-align:center;touch-action:manipulation;">' + label + '</button>')
                .click(function(e) {
                    e.stopPropagation();
                    var count = countOldData(days);
                    if (count === 0) {
                        alert('没有超过 ' + days + ' 天未更新的记录需要清除。');
                        return;
                    }
                    if (!confirm('⚠️ 确定要清除 ' + count + ' 条超过 ' + days + ' 天未更新的记录吗?\n此操作不可撤销!')) {
                        return;
                    }
                    var cleared = clearOldData(days);
                    alert('已清除 ' + cleared + ' 条超过 ' + days + ' 天未更新的记录。');
                    hidePanel();
                });
        }

        clearBtnGroup.append(
            createClearBtn(30, '30天', '#333', '#ffc107'),
            createClearBtn(60, '60天', '#fff', '#ff9800'),
            createClearBtn(90, '90天', '#fff', '#f44336')
        );

        var clearAllBtn = $('<button style="background:#dc3545;color:#fff;border:0;border-radius:4px;padding:5px 10px;cursor:pointer;font-size:12px;text-align:center;touch-action:manipulation;width:80px;">🗑️ 清空全部</button>')
            .click(function(e) {
                e.stopPropagation();
                var keys = [];
                for (var i = 0; i < localStorage.length; i++) {
                    var key = localStorage.key(i);
                    if (key && key.startsWith('kh_pos_')) {
                        keys.push(key);
                    }
                }
                if (keys.length === 0) {
                    alert('没有记录需要清除。');
                    return;
                }
                if (!confirm('⚠️ 确定要清空全部 ' + keys.length + ' 条阅读进度记录吗?\n此操作不可撤销!')) {
                    return;
                }
                keys.forEach(function(k) {
                    localStorage.removeItem(k);
                });
                alert('已清空 ' + keys.length + ' 条记录。');
                hidePanel();
                setTimeout(function() {
                    location.reload();
                }, 300);
            });

        btnArea.append(exportBtn, importBtn, clearLabel, clearBtnGroup, clearAllBtn);
        panel.append(btnArea);
        $('body').append(panel);
    }

    function hidePanel() {
        var panel = $('#kh-panel');
        if (!panel.length || panel.css('display') === 'none') {
            return;
        }
        panel.css({
            opacity: '0',
            transform: 'scale(0.9) translateY(8px)',
            transition: 'opacity 0.15s ease-out, transform 0.15s ease-out'
        });
        setTimeout(function() {
            panel.hide();
            panel.css({
                opacity: '1',
                transform: 'scale(1) translateY(0px)',
                transition: 'none'
            });
        }, 180);
    }

    $(document).on('click', function(e) {
        var panel = $('#kh-panel');
        if (panel.length && panel.is(':visible')) {
            var target = e.target;
            if (!panel.is(target) && panel.has(target).length === 0) {
                hidePanel();
            }
        }
    });

    function getLatestChapter(workId) {
        var lastChapter = localStorage.getItem('kh_last_chapter_' + workId);
        if (lastChapter) {
            return parseInt(lastChapter, 10);
        }
        var posData = localStorage.getItem('kh_pos_' + workId);
        if (posData) {
            try {
                var pos = JSON.parse(posData);
                if (pos.chapterNum) {
                    return pos.chapterNum;
                }
            } catch(e) {}
        }
        return null;
    }

    function addContinueReadingIcons() {
        var blurbs = $('li.work.blurb, li.bookmark.blurb').not('.deleted');
        blurbs.each(function() {
            var blurb = $(this);
            var workId = getWorkId(blurb);
            if (!workId) {
                return;
            }

            var heading = blurb.find('h4.heading');
            if (!heading.length || heading.find('.kh-continue-icon').length) {
                return;
            }

            var posData = localStorage.getItem('kh_pos_' + workId);
            if (!posData) {
                return;
            }

            try {
                JSON.parse(posData);
            } catch (e) {
                return;
            }

            var targetUrl = '/works/' + workId;
            var chapterNum = getLatestChapter(workId);
            if (chapterNum) {
                targetUrl += '/chapters/' + chapterNum;
            }

            var icon = $('<a href="' + targetUrl + '" title="继续阅读" class="kh-continue-icon" style="cursor:pointer;text-decoration:none;margin-left:8px;font-size:1.1em;display:inline-block;">📑</a>')
                .click(function(e) {
                    e.preventDefault();
                    e.stopPropagation();
                    location.href = this.href;
                });
            heading.append(icon);
        });
    }

    function getWorkId(blurb) {
        var blurbId = blurb.attr('id');
        if (blurb.hasClass('work')) {
            return blurbId.replace('work_', '');
        }
        if (blurb.hasClass('bookmark')) {
            var link = blurb.find('h4 a:first').attr('href');
            if (link && link.indexOf('series') === -1 && link.indexOf('external_work') === -1) {
                return link.split('/').pop();
            }
        }
        return null;
    }

    function initScrollMemory(workId) {
        setTimeout(function() {
            var data = localStorage.getItem('kh_pos_' + workId);
            if (data) {
                try {
                    var pos = JSON.parse(data);
                    var targetChapter = null;
                    var y = pos.scrollY || 0;

                    if (pos.chapter) {
                        var el = document.getElementById(pos.chapter);
                        if (el) {
                            targetChapter = $(el);
                        }
                    }

                    if (!targetChapter && pos.chapterNum) {
                        var chapters = $("div.chapter[id^='chapter-']");
                        var idx = pos.chapterNum - 1;
                        if (idx >= 0 && idx < chapters.length) {
                            targetChapter = chapters[idx];
                        }
                    }

                    if (targetChapter && typeof pos.offset === 'number') {
                        y = targetChapter[0].offsetTop + targetChapter[0].offsetHeight * pos.offset;
                    }
                    window.scrollTo({ top: y, behavior: 'smooth' });
                } catch (e) {}
            }
        }, 600);

        var timer;
        $(window).on('scroll', function() {
            clearTimeout(timer);
            timer = setTimeout(function() {
                var chapters = $("div.chapter[id^='chapter-']");
                var chapId = 'full_work';
                var chapNum = null;

                if (chapters.length > 1) {
                    var center = window.innerHeight / 2;
                    var idx = 0;
                    chapters.each(function(i) {
                        if (this.getBoundingClientRect().top <= center) {
                            chapId = this.id;
                            idx = i;
                        }
                    });
                    chapNum = idx + 1;
                } else {
                    var urlMatch = location.pathname.match(/\/chapters\/(\d+)/);
                    if (urlMatch) {
                        chapId = 'chapter-' + urlMatch[1];
                        chapNum = parseInt(urlMatch[1], 10);
                    }
                }

                var el = document.getElementById(chapId);
                var offset = 0;
                if (el) {
                    offset = Math.max(0, Math.min(1, (window.scrollY - el.offsetTop) / (el.offsetHeight || 1)));
                }

                var data = JSON.stringify({
                    chapter: chapId,
                    chapterNum: chapNum,
                    offset: offset,
                    scrollY: window.scrollY,
                    updated: Date.now()
                });
                localStorage.setItem('kh_pos_' + workId, data);
                if (chapNum !== null) {
                    localStorage.setItem('kh_last_chapter_' + workId, String(chapNum));
                }
            }, 1500);
        });
    }

    function getWorkIdFromPage() {
        var match = location.pathname.match(/\/works\/(\d+)/);
        return match ? match[1] : null;
    }

    updateMenuCommand();

    if (settings.showFloatingIcon) {
        createFloatingIcon();
        createPanel();
    }

    if ($('li.work.blurb, li.bookmark.blurb').length) {
        var observer = new MutationObserver(function() {
            addContinueReadingIcons();
        });
        observer.observe(document.body, { childList: true, subtree: true });
        setTimeout(addContinueReadingIcons, 500);
    }

    if ($('#workskin').length) {
        var workId = getWorkIdFromPage();
        if (workId) {
            initScrollMemory(workId);
        }
    }

})(jQuery);