closed-editor-bridge

Paste Markdown into closed rich-text editors segment by segment, following manually uploaded images

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

/*
 * File: closed-editor-bridge.js
 * Project: browser-scripts
 * Created: 2026-07-09
 * Author: Victor Cheng
 * Email: [email protected]
 * Description: Bridge Markdown content into closed rich-text editors by inserting text segment-by-segment at the cursor
 */

// ==UserScript==
// @name         closed-editor-bridge
// @namespace    http://tampermonkey.net/
// @version      1.0.4
// @description  Paste Markdown into closed rich-text editors segment by segment, following manually uploaded images
// @author       Victor Cheng
// @match        *://*/*
// @grant        none
// @license      MIT
// @run-at       document-idle
// @inject-into  page
// ==/UserScript==

(function() {
    'use strict';

    //=======================================
    // 配置
    //=======================================
    const CONFIG = {
        STORAGE_KEY: 'closedEditorBridge',
        SHORTCUTS: {
            INSERT: { key: 'k', alt: true },   // Alt+K 插入当前段并推进
            PREV: { key: 'j', alt: true }      // Alt+J 回退到上一段
        },
        PREVIEW_CHARS: 200,
        MIN_EDITOR_WIDTH: 80,
        MIN_EDITOR_HEIGHT: 80,
        DETECT_POLL_MS: 1000,
        DETECT_POLL_MAX_MS: 60000,
        CURSOR_ADVANCE_MS: 50,
        DRAG_THRESHOLD_PX: 4,
        STYLE_ID: 'ceb-styles'
    };

    //=======================================
    // 状态
    //=======================================
    let state = {
        rawText: '',          // 用户粘贴的原文
        gaps: [],             // 解析后的 gap 列表 [{ text, skipped }]
        currentIndex: 0,      // 当前 gap 指针
        skippedCount: 0,      // 被跳过的空 gap 数
        collapsed: false,     // 面板折叠状态
        position: null,       // 面板左上角位置 { left, top }
        lastResult: ''        // 最近一次插入结果提示
    };

    // 记录最后一次聚焦的输入框,用于在点击面板按钮后找回焦点
    let lastFocusedElement = null;
    let cursorAdvanceTimer = null;
    let bootstrapped = false;

    function log(message, ...args) {
        console.log(`[closed-editor-bridge] ${message}`, ...args);
    }

    function storageKey() {
        return `${CONFIG.STORAGE_KEY}:${location.pathname || '/'}`;
    }

    function isEditableTarget(el) {
        if (!el || el.nodeType !== 1) return false;
        if (el.id === 'ceb-input') return false;
        return !!(el.isContentEditable || el.tagName === 'INPUT' || el.tagName === 'TEXTAREA');
    }

    function rememberFocusedElement(el) {
        if (isEditableTarget(el)) {
            lastFocusedElement = el;
        }
    }

    function handleFocusIn(e) {
        rememberFocusedElement(e.target);
    }

    // 主文档聚焦监听(iframe 内在 Shortcuts 中绑定)
    document.addEventListener('focusin', handleFocusIn);

    //=======================================
    // Markdown 解析与清洗
    //=======================================
    const Parser = {
        /**
         * 解析 Markdown 全文,生成 gap 列表
         */
        parse(rawText) {
            const body = rawText.trim();
            const chunks = this.splitByImages(body);
            const gaps = [];
            let skippedCount = 0;

            chunks.forEach((chunk) => {
                const isBlank = !chunk.trim();
                gaps.push({
                    text: chunk,
                    skipped: isBlank
                });
                if (isBlank) skippedCount++;
            });

            return { gaps, skippedCount };
        },

        /**
         * 收集 fenced code block 区间,避免代码块内的图片语法被误切
         */
        getFencedCodeRanges(body) {
            const ranges = [];
            const fenceRe = /^ {0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?^ {0,3}\1[ \t]*$/gm;
            let match;
            while ((match = fenceRe.exec(body)) !== null) {
                ranges.push([match.index, match.index + match[0].length]);
            }
            return ranges;
        },

        isInsideRanges(index, ranges) {
            return ranges.some(([start, end]) => index >= start && index < end);
        },

        /**
         * 以 Markdown 图片语法 ![alt](url) 为切割点,把正文切成 N+1 个 gap
         * 跳过 fenced code block 内的匹配
         */
        splitByImages(body) {
            const imageRegex = /!\[[^\]]*\]\([^)]+\)/g;
            const codeRanges = this.getFencedCodeRanges(body);
            const chunks = [];
            let lastIndex = 0;
            let match;

            while ((match = imageRegex.exec(body)) !== null) {
                if (this.isInsideRanges(match.index, codeRanges)) {
                    continue;
                }
                chunks.push(body.slice(lastIndex, match.index));
                lastIndex = match.index + match[0].length;
            }
            chunks.push(body.slice(lastIndex));

            return chunks;
        },

        /**
         * 按空行切成段落
         */
        splitParagraphs(gapText) {
            const lines = gapText.split('\n');
            const paragraphs = [];
            let current = [];

            for (const line of lines) {
                const trimmed = line.trim();
                if (trimmed === '') {
                    if (current.length) {
                        paragraphs.push(current.join('\n'));
                        current = [];
                    }
                } else {
                    current.push(trimmed);
                }
            }
            if (current.length) paragraphs.push(current.join('\n'));
            return paragraphs;
        },

        /**
         * 转换为富文本 HTML(用于 contenteditable)
         */
        toHtml(gapText) {
            return this.splitParagraphs(gapText)
                .map(text => `<p>${this.inlineClean(text, true)}</p>`)
                .join('');
        },

        /**
         * 转换为纯文本(用于 textarea/input)
         */
        toPlainText(gapText) {
            return this.splitParagraphs(gapText)
                .map(text => this.inlineClean(text, false))
                .join('\n\n');
        },

        escapeAttr(value) {
            return String(value)
                .replace(/&/g, '&amp;')
                .replace(/"/g, '&quot;')
                .replace(/'/g, '&#39;')
                .replace(/</g, '&lt;')
                .replace(/>/g, '&gt;');
        },

        /**
         * 仅允许安全链接协议,拒绝 javascript: 等
         */
        sanitizeHref(url) {
            const trimmed = String(url || '').trim();
            if (!trimmed) return null;
            if (/^https?:\/\//i.test(trimmed)) return trimmed;
            if (/^mailto:/i.test(trimmed)) return trimmed;
            if (trimmed.startsWith('/') || trimmed.startsWith('#') ||
                trimmed.startsWith('./') || trimmed.startsWith('../')) {
                return trimmed;
            }
            return null;
        },

        /**
         * 行内 Markdown 标记清洗
         */
        inlineClean(text, toHtml = true) {
            if (toHtml) {
                text = text
                    .replace(/&/g, '&amp;')
                    .replace(/</g, '&lt;')
                    .replace(/>/g, '&gt;');
            }

            text = text.replace(/^#{1,6}\s*/gm, '');
            text = text.replace(/^\s*[-*+]\s+/gm, '');
            text = text.replace(/^\s*\d+\.\s+/gm, '');
            text = text.replace(/^\s*(?:&gt;|>)\s*/gm, '');
            text = text.replace(/\*\*([^*]+)\*\*/g, '$1');
            text = text.replace(/\*([^*]+)\*/g, '$1');
            text = text.replace(/__([^_]+)__/g, '$1');
            text = text.replace(/_([^_]+)_/g, '$1');
            text = text.replace(/`([^`]+)`/g, '$1');

            if (toHtml) {
                text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => {
                    const safe = this.sanitizeHref(href);
                    if (!safe) return label;
                    return `<a href="${this.escapeAttr(safe)}" target="_blank" rel="noopener noreferrer" style="color: #2563eb; text-decoration: underline;">${label}</a>`;
                });
                text = text.replace(/\n/g, '<br>');
            } else {
                text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
            }
            return text;
        }
    };

    //=======================================
    // 存储
    //=======================================
    const Storage = {
        load() {
            try {
                const data = localStorage.getItem(storageKey());
                if (data) return JSON.parse(data);

                // 兼容旧版全局 key
                const legacy = localStorage.getItem(CONFIG.STORAGE_KEY);
                return legacy ? JSON.parse(legacy) : null;
            } catch (e) {
                log('读取存储失败:', e);
                return null;
            }
        },

        save() {
            try {
                localStorage.setItem(storageKey(), JSON.stringify({
                    rawText: state.rawText,
                    gaps: state.gaps,
                    currentIndex: state.currentIndex,
                    skippedCount: state.skippedCount,
                    collapsed: state.collapsed,
                    position: state.position
                }));
            } catch (e) {
                log('保存存储失败:', e);
            }
        },

        clear() {
            try {
                localStorage.removeItem(storageKey());
                localStorage.removeItem(CONFIG.STORAGE_KEY);
            } catch (e) {
                log('清除存储失败:', e);
            }
        }
    };

    //=======================================
    // 插入器
    //=======================================
    const Inserter = {
        getCodeMirror(target) {
            const wrapper = typeof target.closest === 'function' ? target.closest('.CodeMirror') : null;
            const editor = wrapper?.CodeMirror;
            return editor && typeof editor.getValue === 'function' && typeof editor.replaceSelection === 'function'
                ? editor
                : null;
        },

        /**
         * 在当前光标位置插入当前 gap 的内容
         */
        insertCurrent() {
            const gap = state.gaps[state.currentIndex];
            if (!gap) {
                state.lastResult = 'No segment to insert';
                return false;
            }
            if (gap.skipped) {
                state.lastResult = 'Current segment is empty and skipped';
                return false;
            }

            let target = document.activeElement;
            if (target && target.tagName === 'IFRAME') {
                try {
                    const doc = target.contentDocument || target.contentWindow.document;
                    if (doc && doc.activeElement) {
                        target = doc.activeElement;
                    }
                } catch (e) {
                    // 跨域安全限制
                }
            }

            if (!isEditableTarget(target)) {
                target = lastFocusedElement;
            }

            if (!target) {
                state.lastResult = 'Click inside the editor first';
                return false;
            }

            const doc = target.ownerDocument || document;
            const isTextControl = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA';

            if (isTextControl) {
                const plainText = Parser.toPlainText(gap.text);
                if (!plainText) {
                    state.lastResult = 'Current segment is empty after cleanup';
                    return false;
                }
                const wrappedText = '\n' + plainText + '\n';
                const codeMirror = this.getCodeMirror(target);

                if (codeMirror) {
                    const oldValue = codeMirror.getValue();
                    let ok = false;
                    try {
                        codeMirror.focus();
                        codeMirror.replaceSelection(wrappedText, 'end');
                        ok = codeMirror.getValue() !== oldValue;
                    } catch (e) {
                        log('CodeMirror 插入失败:', e);
                    }

                    if (ok) {
                        state.lastResult = `✓ Inserted segment ${state.currentIndex + 1}/${state.gaps.length}`;
                        log(`插入成功 gap ${state.currentIndex + 1}`);
                    } else {
                        state.lastResult = `✗ Failed to insert segment ${state.currentIndex + 1}`;
                    }
                    return ok;
                }

                target.focus();
                const oldVal = target.value;
                let ok = false;
                try {
                    ok = doc.execCommand('insertText', false, wrappedText);
                } catch (e) {
                    ok = false;
                }

                if (!ok || target.value === oldVal) {
                    const start = target.selectionStart;
                    const end = target.selectionEnd;
                    const val = target.value;
                    target.value = val.slice(0, start) + wrappedText + val.slice(end);
                    target.selectionStart = target.selectionEnd = start + wrappedText.length;
                    target.dispatchEvent(new Event('input', { bubbles: true }));
                    target.dispatchEvent(new Event('change', { bubbles: true }));
                    ok = target.value !== oldVal;
                }

                if (ok) {
                    state.lastResult = `✓ Inserted segment ${state.currentIndex + 1}/${state.gaps.length}`;
                    log(`插入成功 gap ${state.currentIndex + 1}`);
                    this.scheduleCursorAdvance(target);
                } else {
                    state.lastResult = `✗ Failed to insert segment ${state.currentIndex + 1}`;
                }
                return ok;

            } else {
                const html = Parser.toHtml(gap.text);
                if (!html) {
                    state.lastResult = 'Current segment is empty after cleanup';
                    return false;
                }
                const wrappedHtml = '<p><br></p>' + html + '<p><br></p>';

                target.focus();
                let ok = false;
                try {
                    ok = doc.execCommand('insertHTML', false, wrappedHtml);
                } catch (e) {
                    ok = false;
                }

                if (ok) {
                    state.lastResult = `✓ Inserted segment ${state.currentIndex + 1}/${state.gaps.length}`;
                    log(`插入成功 gap ${state.currentIndex + 1}`);
                    this.scheduleCursorAdvance(target);
                } else {
                    state.lastResult = `✗ Failed to insert segment ${state.currentIndex + 1}`;
                }
                return ok;
            }
        },

        scheduleCursorAdvance(element) {
            if (cursorAdvanceTimer) {
                clearTimeout(cursorAdvanceTimer);
            }
            cursorAdvanceTimer = setTimeout(() => {
                cursorAdvanceTimer = null;
                this.simulateArrowDown(element);
            }, CONFIG.CURSOR_ADVANCE_MS);
        },

        /**
         * 模拟键盘向下方向键的动作以移动光标
         */
        simulateArrowDown(element) {
            const doc = element.ownerDocument || document;
            const win = doc.defaultView || window;

            const eventInitDown = { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40, which: 40, bubbles: true, cancelable: true };
            element.dispatchEvent(new KeyboardEvent('keydown', eventInitDown));
            element.dispatchEvent(new KeyboardEvent('keyup', eventInitDown));

            const eventInitRight = { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39, which: 39, bubbles: true, cancelable: true };
            element.dispatchEvent(new KeyboardEvent('keydown', eventInitRight));
            element.dispatchEvent(new KeyboardEvent('keyup', eventInitRight));

            if (element.isContentEditable) {
                try {
                    const selection = win.getSelection();
                    if (selection && selection.rangeCount > 0) {
                        const range = selection.getRangeAt(0);
                        let node = range.startContainer;

                        while (node && node !== element && !['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(node.tagName)) {
                            node = node.parentNode;
                        }

                        if (node && node !== element) {
                            let next = node.nextElementSibling;
                            // 插入包装已带尾部空段;仅在缺失时补一个,避免绕过编辑器模型滥造节点
                            if (!next && node.parentNode && node.parentNode !== element && element.contains(node.parentNode)) {
                                next = node.nextElementSibling;
                            }
                            if (!next && node.parentNode) {
                                next = doc.createElement('p');
                                next.innerHTML = '<br>';
                                node.parentNode.insertBefore(next, node.nextSibling);
                            }
                            if (next) {
                                const newRange = doc.createRange();
                                newRange.selectNodeContents(next);
                                newRange.collapse(false);
                                selection.removeAllRanges();
                                selection.addRange(newRange);
                            }
                        }
                    }
                } catch (err) {
                    log('模拟光标下移/右移失败:', err);
                }
            }
        }
    };

    //=======================================
    // UI 面板
    //=======================================
    const UI = {
        panel: null,
        elements: {},
        suppressCollapseClick: false,

        create() {
            const existing = document.getElementById('ceb-panel');
            if (existing) existing.remove();

            const panel = document.createElement('div');
            panel.id = 'ceb-panel';
            panel.innerHTML = `
                <div id="ceb-header">
                    <span id="ceb-title">Markdown Segment Paste Queue</span>
                    <button type="button" id="ceb-collapse" title="Collapse / expand">—</button>
                </div>
                <div id="ceb-body">
                    <textarea id="ceb-input" placeholder="Paste Markdown body here... (image markers will be used as split points)"></textarea>
                    <div id="ceb-actions">
                        <button type="button" id="ceb-prev">Previous (Alt/Option+J)</button>
                        <button type="button" id="ceb-insert">Insert (Alt/Option+K)</button>
                        <button type="button" id="ceb-next">Next</button>
                        <button type="button" id="ceb-reset">Reset</button>
                    </div>
                    <div id="ceb-status"></div>
                    <div id="ceb-preview"></div>
                    <div id="ceb-hint"></div>
                </div>
            `;
            document.body.appendChild(panel);

            this.panel = panel;
            this.elements = {
                header: panel.querySelector('#ceb-header'),
                collapse: panel.querySelector('#ceb-collapse'),
                input: panel.querySelector('#ceb-input'),
                insert: panel.querySelector('#ceb-insert'),
                prev: panel.querySelector('#ceb-prev'),
                next: panel.querySelector('#ceb-next'),
                reset: panel.querySelector('#ceb-reset'),
                status: panel.querySelector('#ceb-status'),
                preview: panel.querySelector('#ceb-preview'),
                hint: panel.querySelector('#ceb-hint')
            };

            this.injectStyles();
            this.bindEvents();
        },

        injectStyles() {
            if (document.getElementById(CONFIG.STYLE_ID)) return;

            const css = `
                #ceb-panel {
                    position: fixed;
                    top: 16px;
                    right: 16px;
                    width: 320px;
                    background: #fff;
                    border: 1px solid #d0d0d0;
                    border-radius: 8px;
                    box-shadow: 0 4px 16px rgba(0,0,0,0.15);
                    z-index: 2147483647;
                    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
                    font-size: 13px;
                    color: #222;
                }
                #ceb-header {
                    display: flex;
                    justify-content: space-between;
                    align-items: center;
                    padding: 8px 12px;
                    background: #f5f5f5;
                    border-bottom: 1px solid #e0e0e0;
                    border-radius: 8px 8px 0 0;
                    cursor: grab;
                    touch-action: none;
                }
                #ceb-title { font-weight: 600; font-size: 13px; }
                #ceb-collapse {
                    border: none; background: none; cursor: pointer;
                    font-size: 16px; color: #666; padding: 0 4px;
                }
                #ceb-body { padding: 10px 12px; }
                #ceb-panel.ceb-collapsed {
                    width: 40px !important;
                    height: 40px !important;
                    border-radius: 50% !important;
                    overflow: hidden !important;
                    background: #2563eb !important;
                    border: 1px solid #2563eb !important;
                    box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4) !important;
                    cursor: grab !important;
                    transition: transform 0.2s ease, background 0.2s ease !important;
                }
                #ceb-panel.dragging, #ceb-panel.dragging * {
                    cursor: grabbing !important;
                    user-select: none !important;
                }
                #ceb-panel.dragging { transform: none !important; }
                #ceb-panel.ceb-collapsed:hover {
                    transform: scale(1.05) !important;
                    background: #1d4ed8 !important;
                    border-color: #1d4ed8 !important;
                }
                #ceb-panel.ceb-collapsed #ceb-header {
                    width: 100% !important;
                    height: 100% !important;
                    padding: 0 !important;
                    background: transparent !important;
                    border: none !important;
                    display: flex !important;
                    justify-content: center !important;
                    align-items: center !important;
                    cursor: inherit !important;
                }
                #ceb-panel.ceb-collapsed #ceb-title {
                    display: none !important;
                }
                #ceb-panel.ceb-collapsed #ceb-collapse {
                    width: 100% !important;
                    height: 100% !important;
                    color: #fff !important;
                    font-size: 18px !important;
                    display: flex !important;
                    justify-content: center !important;
                    align-items: center !important;
                    padding: 0 !important;
                    border: none !important;
                    background: transparent !important;
                    cursor: inherit !important;
                }
                #ceb-panel.ceb-collapsed #ceb-body {
                    display: none !important;
                }
                #ceb-input {
                    width: 100%; height: 120px;
                    box-sizing: border-box;
                    border: 1px solid #ccc; border-radius: 4px;
                    padding: 6px; font-size: 12px;
                    resize: vertical; font-family: inherit;
                }
                #ceb-actions {
                    display: flex; flex-wrap: wrap; gap: 4px;
                    margin: 8px 0;
                }
                #ceb-panel #ceb-actions button {
                    padding: 4px 8px !important;
                    border: 1px solid #bbb !important;
                    background: #fafafa !important;
                    color: #222 !important;
                    border-radius: 4px !important;
                    cursor: pointer !important;
                    font-size: 12px !important;
                    font-family: inherit !important;
                }
                #ceb-panel #ceb-actions button:hover { background: #e8e8e8 !important; }
                #ceb-panel #ceb-actions button:disabled { opacity: 0.4 !important; cursor: not-allowed !important; background: #fafafa !important; color: #aaa !important; }
                #ceb-panel #ceb-insert { background: #2563eb !important; color: #fff !important; border-color: #2563eb !important; }
                #ceb-panel #ceb-insert:hover { background: #1d4ed8 !important; }
                #ceb-panel #ceb-insert:disabled { background: #cbd5e1 !important; color: #94a3b8 !important; border-color: #cbd5e1 !important; }
                #ceb-status {
                    padding: 6px 8px;
                    background: #f8f8f8;
                    border-radius: 4px;
                    font-size: 12px;
                    color: #444;
                    margin-bottom: 6px;
                }
                #ceb-preview {
                    max-height: 120px;
                    overflow-y: auto;
                    padding: 6px 8px;
                    background: #fafafa;
                    border: 1px solid #eee;
                    border-radius: 4px;
                    font-size: 12px;
                    color: #555;
                    white-space: pre-wrap;
                    margin-bottom: 6px;
                }
                #ceb-hint {
                    font-size: 11px;
                    color: #888;
                }
            `;
            const style = document.createElement('style');
            style.id = CONFIG.STYLE_ID;
            style.textContent = css;
            document.head.appendChild(style);
        },

        bindEvents() {
            this.elements.collapse.addEventListener('click', (e) => {
                if (this.suppressCollapseClick) {
                    e.preventDefault();
                    e.stopPropagation();
                    this.suppressCollapseClick = false;
                    return;
                }
                this.toggleCollapse();
            });
            this.elements.insert.addEventListener('click', () => this.handleInsert());
            this.elements.prev.addEventListener('click', () => this.handlePrev());
            this.elements.next.addEventListener('click', () => this.handleNext());
            this.elements.reset.addEventListener('click', () => this.handleReset());

            this.elements.input.addEventListener('input', () => {
                this.applyParsedInput(this.elements.input.value, { preserveIndex: true });
            });

            this.enableDrag();
        },

        /**
         * 解析输入;preserveIndex 时尽量保留当前队列进度
         */
        applyParsedInput(raw, { preserveIndex = false } = {}) {
            const prevIndex = state.currentIndex;
            const hadGaps = state.gaps.length > 0;

            if (!raw.trim()) {
                state = {
                    rawText: '',
                    gaps: [],
                    currentIndex: 0,
                    skippedCount: 0,
                    collapsed: state.collapsed,
                    position: state.position,
                    lastResult: 'Cleared'
                };
                Storage.save();
                this.render();
                return;
            }

            state.rawText = raw;
            const { gaps, skippedCount } = Parser.parse(raw);
            state.gaps = gaps;
            state.skippedCount = skippedCount;

            if (preserveIndex && hadGaps) {
                state.currentIndex = this.resolvePreservedIndex(prevIndex);
                state.lastResult = `Auto-parsed ${gaps.length} segments (${skippedCount} empty skipped), kept progress`;
            } else {
                const first = this.findNextInsertableIndex(0, 1);
                state.currentIndex = first === -1 ? 0 : first;
                state.lastResult = `Auto-parsed ${gaps.length} segments (${skippedCount} empty skipped)`;
            }

            Storage.save();
            this.render();
        },

        /**
         * 重解析后尽量落在原进度附近的可插入段
         */
        resolvePreservedIndex(prevIndex) {
            if (!state.gaps.length) return 0;

            const clamped = Math.max(0, Math.min(prevIndex, state.gaps.length - 1));
            if (!state.gaps[clamped].skipped) return clamped;

            const forward = this.findNextInsertableIndex(clamped, 1);
            if (forward !== -1) return forward;

            const backward = this.findNextInsertableIndex(clamped, -1);
            if (backward !== -1) return backward;

            return 0;
        },

        enableDrag() {
            const header = this.elements.header;
            const panel = this.panel;
            let isDragging = false;
            let didDrag = false;
            let startedOnCollapse = false;
            let pointerId = null;
            let startX, startY, startLeft, startTop;

            panel.addEventListener('pointerdown', (e) => {
                const isExpandedHeader = header.contains(e.target) && e.target !== this.elements.collapse;
                if (e.button !== 0 || (!state.collapsed && !isExpandedHeader)) return;

                isDragging = true;
                didDrag = false;
                startedOnCollapse = e.target === this.elements.collapse;
                pointerId = e.pointerId;
                startX = e.clientX;
                startY = e.clientY;
                panel.classList.add('dragging');
                const rect = panel.getBoundingClientRect();
                startLeft = rect.left;
                startTop = rect.top;
                panel.setPointerCapture(pointerId);
            });

            panel.addEventListener('pointermove', (e) => {
                if (!isDragging || e.pointerId !== pointerId) return;
                const offsetX = e.clientX - startX;
                const offsetY = e.clientY - startY;
                if (!didDrag && Math.hypot(offsetX, offsetY) < CONFIG.DRAG_THRESHOLD_PX) return;

                didDrag = true;
                this.setPosition(startLeft + offsetX, startTop + offsetY);
                e.preventDefault();
            });

            const finishDrag = (e) => {
                if (!isDragging || e.pointerId !== pointerId) return;
                if (startedOnCollapse && (didDrag || e.type === 'pointerup')) {
                    this.suppressCollapseClick = true;
                    setTimeout(() => { this.suppressCollapseClick = false; }, 0);
                }
                if (!didDrag && startedOnCollapse && e.type === 'pointerup') {
                    this.toggleCollapse();
                }
                if (didDrag) Storage.save();
                panel.classList.remove('dragging');
                isDragging = false;
                pointerId = null;
            };

            panel.addEventListener('pointerup', finishDrag);
            panel.addEventListener('pointercancel', finishDrag);
        },

        setPosition(left, top) {
            const rect = this.panel.getBoundingClientRect();
            const maxLeft = Math.max(0, window.innerWidth - rect.width);
            const maxTop = Math.max(0, window.innerHeight - rect.height);
            const position = {
                left: Math.round(Math.max(0, Math.min(left, maxLeft))),
                top: Math.round(Math.max(0, Math.min(top, maxTop)))
            };

            this.panel.style.left = `${position.left}px`;
            this.panel.style.top = `${position.top}px`;
            this.panel.style.right = 'auto';
            state.position = position;
        },

        restorePosition() {
            if (!state.position || !Number.isFinite(state.position.left) || !Number.isFinite(state.position.top)) return;
            this.setPosition(state.position.left, state.position.top);
        },

        toggleCollapse() {
            const previousRect = this.panel.getBoundingClientRect();
            state.collapsed = !state.collapsed;
            this.panel.classList.toggle('ceb-collapsed', state.collapsed);
            this.elements.collapse.textContent = state.collapsed ? '📝' : '—';
            this.elements.collapse.title = state.collapsed ? 'Drag to move; click to expand' : 'Collapse panel';
            requestAnimationFrame(() => {
                const nextRect = this.panel.getBoundingClientRect();
                this.setPosition(previousRect.right - nextRect.width, previousRect.top);
                Storage.save();
            });
            Storage.save();
        },

        handleInsert() {
            if (document.activeElement === this.elements.input) return;
            const ok = Inserter.insertCurrent();
            if (ok) this.advance();
            Storage.save();
            this.render();
        },

        handlePrev() {
            const prevIndex = this.findNextInsertableIndex(state.currentIndex - 1, -1);
            if (prevIndex !== -1) {
                state.currentIndex = prevIndex;
                state.lastResult = `Back to segment ${state.currentIndex + 1}`;
            }
            Storage.save();
            this.render();
        },

        handleNext() {
            this.advance();
            Storage.save();
            this.render();
        },

        handleReset() {
            state = {
                rawText: '',
                gaps: [],
                currentIndex: 0,
                skippedCount: 0,
                collapsed: false,
                position: null,
                lastResult: 'Reset'
            };
            this.elements.input.value = '';
            this.panel.classList.remove('ceb-collapsed');
            this.panel.style.left = '';
            this.panel.style.top = '';
            this.panel.style.right = '';
            Storage.clear();
            this.render();
        },

        advance() {
            const nextIndex = this.findNextInsertableIndex(state.currentIndex + 1, 1);
            if (nextIndex !== -1) {
                state.currentIndex = nextIndex;
                state.lastResult = `Moved to segment ${state.currentIndex + 1}/${state.gaps.length}`;
            } else {
                state.lastResult = 'Already at the last segment';
            }
        },

        findNextInsertableIndex(start, direction) {
            if (!state.gaps.length) return -1;

            for (let i = start; i >= 0 && i < state.gaps.length; i += direction) {
                if (!state.gaps[i].skipped) return i;
            }
            return -1;
        },

        render() {
            const total = state.gaps.length;
            const current = total && state.currentIndex >= 0 ? state.currentIndex + 1 : 0;
            this.elements.status.innerHTML = `
                <div>Progress: <b>${current}/${total}</b> | Empty skipped: ${state.skippedCount}</div>
                <div>${state.lastResult || 'Paste content to auto-parse'}</div>
            `;

            const gap = state.gaps[state.currentIndex];
            if (gap) {
                const text = gap.skipped ? '(Empty segment, will be skipped)' : gap.text.trim();
                const preview = text.slice(0, CONFIG.PREVIEW_CHARS);
                this.elements.preview.textContent = `Gap ${state.currentIndex + 1}:\n${preview}${text.length > CONFIG.PREVIEW_CHARS ? '...' : ''}`;
            } else {
                this.elements.preview.textContent = '(No content)';
            }

            this.elements.hint.textContent = 'Click inside the editor, press Alt/Option+K to insert, Alt/Option+J to go back';

            this.elements.prev.disabled = this.findNextInsertableIndex(state.currentIndex - 1, -1) === -1;
            this.elements.next.disabled = this.findNextInsertableIndex(state.currentIndex + 1, 1) === -1;
            this.elements.insert.disabled = !gap || gap.skipped;
        },

        restore() {
            const saved = Storage.load();
            if (saved) {
                Object.assign(state, saved, {
                    gaps: Array.isArray(saved.gaps) ? saved.gaps : [],
                    collapsed: !!saved.collapsed,
                    position: saved.position && Number.isFinite(saved.position.left) && Number.isFinite(saved.position.top)
                        ? { left: saved.position.left, top: saved.position.top }
                        : null
                });
                if (state.gaps[state.currentIndex] && state.gaps[state.currentIndex].skipped) {
                    const nextIndex = this.findNextInsertableIndex(state.currentIndex, 1);
                    state.currentIndex = nextIndex === -1 ? 0 : nextIndex;
                }
                state.lastResult = state.gaps.length ? 'Restored previous progress' : state.lastResult;
                this.elements.input.value = state.rawText;
            }
            this.panel.classList.toggle('ceb-collapsed', !!state.collapsed);
            this.elements.collapse.textContent = state.collapsed ? '📝' : '—';
            this.elements.collapse.title = state.collapsed ? 'Drag to move; click to expand' : 'Collapse panel';
            this.restorePosition();
            this.render();
        }
    };

    const Shortcuts = {
        iframeObserver: null,

        init() {
            if (window.ceb_keydown_listener) {
                document.removeEventListener('keydown', window.ceb_keydown_listener, true);
                document.querySelectorAll('iframe').forEach(iframe => {
                    this.unbindIframe(iframe);
                });
            }

            window.ceb_keydown_listener = (e) => this.handle(e);
            document.addEventListener('keydown', window.ceb_keydown_listener, true);

            document.querySelectorAll('iframe').forEach(iframe => this.bindIframe(iframe));

            if (this.iframeObserver) {
                this.iframeObserver.disconnect();
            }
            this.iframeObserver = new MutationObserver((mutations) => {
                mutations.forEach((mutation) => {
                    mutation.addedNodes.forEach((node) => {
                        if (node.nodeType !== 1) return;
                        if (node.tagName === 'IFRAME') {
                            this.bindIframe(node);
                        } else if (node.querySelectorAll) {
                            node.querySelectorAll('iframe').forEach(iframe => this.bindIframe(iframe));
                        }
                    });
                });
            });
            this.iframeObserver.observe(document.documentElement || document.body, {
                childList: true,
                subtree: true
            });
        },

        unbindIframe(iframe) {
            try {
                const doc = iframe.contentDocument || iframe.contentWindow.document;
                if (!doc) return;
                if (window.ceb_keydown_listener) {
                    doc.removeEventListener('keydown', window.ceb_keydown_listener, true);
                }
                doc.removeEventListener('focusin', handleFocusIn);
            } catch (e) {
                // 跨域
            }
        },

        bindIframe(iframe) {
            if (!iframe) return;

            const attach = () => {
                try {
                    const doc = iframe.contentDocument || iframe.contentWindow.document;
                    if (!doc) return;

                    if (window.ceb_keydown_listener) {
                        doc.removeEventListener('keydown', window.ceb_keydown_listener, true);
                        doc.addEventListener('keydown', window.ceb_keydown_listener, true);
                    }
                    doc.removeEventListener('focusin', handleFocusIn);
                    doc.addEventListener('focusin', handleFocusIn);
                } catch (e) {
                    // 忽略跨域 iframe 权限限制
                }
            };

            attach();
            if (iframe.dataset.cebLoadBound !== '1') {
                iframe.dataset.cebLoadBound = '1';
                iframe.addEventListener('load', attach);
            }
        },

        handle(e) {
            const sc = CONFIG.SHORTCUTS;
            const matches = (shortcut) => {
                const key = (e.key || '').toLowerCase();
                const physicalKey = `Key${shortcut.key.toUpperCase()}`;
                return e.altKey === shortcut.alt && !e.ctrlKey && !e.metaKey &&
                    (key === shortcut.key || e.code === physicalKey);
            };

            if (matches(sc.INSERT)) {
                if (document.activeElement && document.activeElement.id === 'ceb-input') return;
                e.preventDefault();
                e.stopImmediatePropagation();
                UI.handleInsert();
                return;
            }
            if (matches(sc.PREV)) {
                if (document.activeElement && document.activeElement.id === 'ceb-input') return;
                e.preventDefault();
                e.stopImmediatePropagation();
                UI.handlePrev();
            }
        }
    };

    //=======================================
    // 编辑器检测
    //=======================================
    const Detector = {
        hasMinimumEditorSize(el) {
            const rect = el.getBoundingClientRect();
            const height = rect.height || el.clientHeight;
            const width = rect.width || el.clientWidth;
            return width >= CONFIG.MIN_EDITOR_WIDTH && height >= CONFIG.MIN_EDITOR_HEIGHT;
        },

        hasEditor() {
            const knownEditorSelectors = [
                '.tox-tinymce', '.tox-editor-container',
                '.ck-editor', '.ck-content',
                '.ql-container', '.ql-editor',
                '.w-e-text-container', '.w-e-text',
                '.edui-editor', '.edui-body-container',
                '.note-editable',
                '.DraftEditor-editorContainer',
                '.medium-editor-element',
                '.editormd',
                '.CodeMirror',
                '.trix-content'
            ];
            const knownEditors = document.querySelectorAll(knownEditorSelectors.join(','));
            for (const editor of knownEditors) {
                if (this.hasMinimumEditorSize(editor)) {
                    return true;
                }
            }

            const iframes = document.querySelectorAll('iframe');
            for (const iframe of iframes) {
                try {
                    const doc = iframe.contentDocument || iframe.contentWindow.document;
                    if (doc && this.hasMinimumEditorSize(iframe)) {
                        const iframeEditables = doc.querySelectorAll('[contenteditable=""], [contenteditable="true"]');
                        if ([...iframeEditables].some(el => this.hasMinimumEditorSize(el))) {
                            return true;
                        }
                    }
                } catch (e) {}
            }

            const editables = document.querySelectorAll('[contenteditable=""], [contenteditable="true"]');
            for (const el of editables) {
                if (el.id === 'ceb-input') continue;
                if (!this.hasMinimumEditorSize(el)) continue;

                const hasToolbar = document.querySelector('[class*="toolbar"]') ||
                                  document.querySelector('[id*="toolbar"]') ||
                                  document.querySelector('[role="toolbar"]');
                if (hasToolbar) {
                    return true;
                }
            }

            const textareas = document.querySelectorAll('textarea');
            for (const ta of textareas) {
                if (ta.id === 'ceb-input') continue;
                const rect = ta.getBoundingClientRect();
                const height = rect.height || ta.clientHeight;
                if (height >= 120 || ta.rows >= 8) {
                    const inEditorContainer = ta.closest('[class*="editor"]') || ta.closest('[id*="editor"]');
                    const hasToolbar = document.querySelector('[class*="toolbar"]') ||
                                      document.querySelector('[id*="toolbar"]') ||
                                      document.querySelector('[role="toolbar"]');
                    if (inEditorContainer && hasToolbar) {
                        const name = (ta.name || '').toLowerCase();
                        const id = (ta.id || '').toLowerCase();
                        const placeholder = (ta.placeholder || '').toLowerCase();
                        const ariaLabel = (ta.getAttribute('aria-label') || '').toLowerCase();
                        const isChatOrSearch = /chat|search|reply|comment|send|ask|message|搜索|聊天|评论|回复|提问/i.test(name + id + placeholder + ariaLabel);
                        if (!isChatOrSearch) {
                            return true;
                        }
                    }
                }
            }
            return false;
        }
    };

    //=======================================
    // 初始化(支持 SPA 延迟出现编辑器)
    //=======================================
    function bootstrap() {
        if (bootstrapped) return false;
        if (!Detector.hasEditor()) return false;

        bootstrapped = true;
        UI.create();
        UI.restore();
        Shortcuts.init();
        log('已加载(检测到编辑器)');
        return true;
    }

    function init() {
        const startWatching = () => {
            if (bootstrap()) return;

            const startedAt = Date.now();
            const pollId = setInterval(() => {
                if (bootstrap() || Date.now() - startedAt >= CONFIG.DETECT_POLL_MAX_MS) {
                    clearInterval(pollId);
                }
            }, CONFIG.DETECT_POLL_MS);

            const observer = new MutationObserver(() => {
                if (bootstrap()) {
                    observer.disconnect();
                    clearInterval(pollId);
                }
            });

            const root = document.documentElement || document.body;
            if (root) {
                observer.observe(root, { childList: true, subtree: true });
            }

            setTimeout(() => {
                observer.disconnect();
            }, CONFIG.DETECT_POLL_MAX_MS);
        };

        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', startWatching);
        } else {
            startWatching();
        }
    }

    init();

})();