closed-editor-bridge

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

/*
 * 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();

})();