F-List Profile Editor Overhaul - Live Profile Preview

Adds a live side-panel preview for the character editor that fully inherits the site's theme and component styles.

当前为 2025-09-20 提交的版本,查看 最新版本

// ==UserScript==
// @name         F-List Profile Editor Overhaul - Live Profile Preview
// @namespace    http://tampermonkey.net/
// @version      4.2
// @description  Adds a live side-panel preview for the character editor that fully inherits the site's theme and component styles.
// @author       Gemini
// @match        *://*.f-list.net/character_edit.php*
// @grant        GM_addStyle
// @grant        unsafeWindow
// @run-at       document-start
// @require      https://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min.js
// ==/UserScript==

(function() {
    'use strict';

    // Hide elements immediately to prevent FOUC (Flash of Unstyled Content)
    GM_addStyle('#Sidebar, #Content { visibility: hidden; }');

    /* global $, unsafeWindow, GM_addStyle */

    // -------------------------------------------------
    // START: BBCode Parser (No changes in this section)
    // -------------------------------------------------
    const appendTextWithLineBreaks = (parent, text) => {
        if (!parent || typeof text !== 'string') return;
        const lines = text.split('\n');
        lines.forEach((line, index) => {
            if (line.length > 0) {
                parent.appendChild(document.createTextNode(line));
            }
            if (index < lines.length - 1) {
                parent.appendChild(document.createElement('br'));
            }
        });
    };
    class BBCodeTag {
        noClosingTag = false;
        allowedTags = undefined;
        constructor(tag, tagList) { this.tag = tag; if (tagList !== undefined) this.setAllowedTags(tagList); }
        isAllowed(tag) { return this.allowedTags === undefined || this.allowedTags[tag] !== undefined; }
        setAllowedTags(allowed) { this.allowedTags = {}; for (const tag of allowed) this.allowedTags[tag] = true; }
    }
    class BBCodeSimpleTag extends BBCodeTag {
        constructor(tag, elementName, classes, tagList) { super(tag, tagList); this.elementName = elementName; this.classes = classes; }
        createElement(parser, parent, param) {
            const el = parser.createElement(this.elementName);
            if (this.classes !== undefined && this.classes.length > 0) { el.className = this.classes.join(' '); }
            parent.appendChild(el); return el;
        }
    }
    class BBCodeCustomTag extends BBCodeTag {
        constructor(tag, customCreator, tagList) { super(tag, tagList); this.customCreator = customCreator; }
        createElement(parser, parent, param) { return this.customCreator(parser, parent, param); }
    }
    class BBCodeTextTag extends BBCodeTag {
        constructor(tag, customCreator) { super(tag, []); this.customCreator = customCreator; }
        createElement(parser, parent, param, content) { return this.customCreator(parser, parent, param, content); }
    }
    class BBCodeParser {
        _tags = {}; _line = 1; _column = 1; _currentTag = { tag: '<root>', line: 1, column: 1 };
        addTag(impl) { this._tags[impl.tag] = impl; }
        createElement(tag) { return document.createElement(tag); }
        parseEverything(input) {
            const parent = this.createElement('span'); parent.className = 'bbcode';
            this.parse(input, 0, undefined, parent, () => true, 0); return parent;
        }
        parse(input, start, self, parent, isAllowed, depth) {
            let currentTag = this._currentTag;
            if (self !== undefined) {
                const parentAllowed = isAllowed; isAllowed = name => self.isAllowed(name) && parentAllowed(name);
                currentTag = this._currentTag = { tag: self.tag, line: this._line, column: this._column };
            }
            let tagStart = -1, paramStart = -1, mark = start;
            for (let i = start; i < input.length; ++i) {
                const c = input[i];
                if (c === '\n') { this._line++; this._column = 1; } else { this._column++; }
                if (c === '[') { tagStart = i; paramStart = -1;
                } else if (c === '=' && tagStart !== -1 && paramStart === -1) { paramStart = i;
                } else if (c === ']' && tagStart !== -1) {
                    const paramIndex = paramStart === -1 ? i : paramStart;
                    let tagKey = input.substring(tagStart + 1, paramIndex).trim().toLowerCase();
                    if (tagKey.length === 0) { tagStart = -1; continue; }
                    const param = paramStart > tagStart ? input.substring(paramStart + 1, i) : '';
                    const close = tagKey[0] === '/';
                    if (close) tagKey = tagKey.substr(1).trim();
                    if (this._tags[tagKey] === undefined) { tagStart = -1; continue; }
                    const tag = this._tags[tagKey];
                    if (!close) {
                        if (parent !== undefined) { appendTextWithLineBreaks(parent, input.substring(mark, tagStart)); }
                        mark = i + 1;
                        if (!isAllowed(tagKey) || parent === undefined || depth > 100) {
                            i = this.parse(input, i + 1, tag, undefined, isAllowed, depth + 1); mark = i + 1; continue;
                        }
                        if (tag instanceof BBCodeTextTag) {
                            const endPos = this.parse(input, i + 1, tag, undefined, isAllowed, depth + 1);
                            const contentEnd = input.lastIndexOf('[', endPos);
                            const content = input.substring(mark, contentEnd > mark ? contentEnd : mark);
                            tag.createElement(this, parent, param.trim(), content); i = endPos;
                        } else {
                            const element = tag.createElement(this, parent, param.trim());
                            if (element !== undefined && !tag.noClosingTag) { i = this.parse(input, i + 1, tag, element, isAllowed, depth + 1); }
                        }
                        mark = i + 1; this._currentTag = currentTag;
                    } else if (self !== undefined && self.tag === tagKey) {
                        if (parent !== undefined) { appendTextWithLineBreaks(parent, input.substring(mark, tagStart)); }
                        return i;
                    }
                    tagStart = -1;
                }
            }
            if (mark < input.length && parent !== undefined) { appendTextWithLineBreaks(parent, input.substring(mark)); }
            return input.length;
        }
    }
    // -------------------------------------------------
    // END: BBCode Parser
    // -------------------------------------------------


    // -------------------------------------------------
    // START: F-List Parser Configuration (No changes here)
    // -------------------------------------------------
    function createFListParser() {
        const parser = new BBCodeParser();
        parser.addTag(new BBCodeSimpleTag('b', 'b'));
        parser.addTag(new BBCodeSimpleTag('i', 'i'));
        parser.addTag(new BBCodeSimpleTag('u', 'u'));
        parser.addTag(new BBCodeSimpleTag('s', 's'));
        parser.addTag(new BBCodeSimpleTag('sup', 'sup'));
        parser.addTag(new BBCodeSimpleTag('sub', 'sub'));
        parser.addTag(new BBCodeSimpleTag('quote', 'blockquote'));
        const hrTag = new BBCodeSimpleTag('hr', 'hr');
        hrTag.noClosingTag = true;
        parser.addTag(hrTag);
        parser.addTag(new BBCodeCustomTag('center', (p, parent) => {
            const el = p.createElement('div'); el.style.textAlign = 'center'; parent.appendChild(el); return el;
        }));
        parser.addTag(new BBCodeCustomTag('right', (p, parent) => {
            const el = p.createElement('div'); el.style.textAlign = 'right'; parent.appendChild(el); return el;
        }));
        parser.addTag(new BBCodeCustomTag('justify', (p, parent) => {
            const el = p.createElement('div'); el.style.textAlign = 'justify'; parent.appendChild(el); return el;
        }));
        parser.addTag(new BBCodeCustomTag('color', (p, parent, param) => {
            const el = p.createElement('span');
            if (/^(#([0-9a-f]{3}){1,2}|[a-z]+)$/i.test(param)) { el.style.color = param; }
            parent.appendChild(el); return el;
        }));
        parser.addTag(new BBCodeTextTag('url', (p, parent, param, content) => {
            const a = p.createElement('a');
            const url = (param || content).trim();
            const text = content.trim();
            if (url && (url.startsWith('http://') || url.startsWith('https://'))) {
                a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer nofollow';
                appendTextWithLineBreaks(a, text); parent.appendChild(a);
            } else { appendTextWithLineBreaks(parent, `[url${param ? '=' + param : ''}]${content}[/url]`); }
        }));
        parser.addTag(new BBCodeTextTag('img', (p, parent, param, content) => {
            const img = p.createElement('img'); img.style.maxWidth = '100%';
            if (param) {
                const inlines = unsafeWindow.FList.Inlines.inlines;
                const inlineData = inlines ? inlines[param] : null;
                if (inlineData) {
                    const { hash, extension } = inlineData;
                    img.src = `https://static.f-list.net/images/charinline/${hash.substring(0, 2)}/${hash.substring(2, 4)}/${hash}.${extension}`;
                    img.alt = content.trim();
                } else { appendTextWithLineBreaks(parent, `[img=${param}]${content}[/img]`); return; }
            } else {
                const url = content.trim();
                if (url.startsWith('http://') || url.startsWith('https://')) { img.src = url; }
                else { appendTextWithLineBreaks(parent, `[img]${content}[/img]`); return; }
            }
            parent.appendChild(img);
        }));
        const createUserTag = (p, parent, param, content) => {
            const name = content.trim(); if (!name) return;
            const a = p.createElement('a');
            a.href = `https://www.f-list.net/c/${encodeURIComponent(name)}`; a.target = '_blank'; a.className = 'character-icon';
            const img = p.createElement('img');
            img.src = `https://static.f-list.net/images/avatar/${name.toLowerCase().replace(/ /g, '%20')}.png`;
            img.style.cssText = 'width:50px; height:50px; vertical-align:middle; margin-right:5px; border: 0;';
            a.appendChild(img); appendTextWithLineBreaks(a, name); parent.appendChild(a);
        };
        parser.addTag(new BBCodeTextTag('icon', createUserTag));
        parser.addTag(new BBCodeTextTag('user', createUserTag));
        parser.addTag(new BBCodeCustomTag('collapse', (p, parent, param) => {
            const header = p.createElement('div'); header.className = 'CollapseHeader';
            const headerText = p.createElement('div'); headerText.className = 'CollapseHeaderText';
            const headerSpan = p.createElement('span');
            appendTextWithLineBreaks(headerSpan, param || '\u00A0');
            headerText.appendChild(headerSpan); header.appendChild(headerText);
            const block = p.createElement('div'); block.className = 'CollapseBlock';
            block.style.display = 'none';
            parent.appendChild(header);
            parent.appendChild(block);
            $(header).on('click', function() {
                $(this).toggleClass('ExpandedHeader');
                $(block).slideToggle(200);
            });
            return block;
        }));
        parser.addTag(new BBCodeTextTag('noparse', (p, parent, param, content) => {
            appendTextWithLineBreaks(parent, content);
        }));
        parser.addTag(new BBCodeTextTag('session', (p, parent, param, content) => {
            const a = p.createElement('a');
            a.href = '#';
            a.onclick = () => false;
            a.className = 'SessionLink';
            a.textContent = content.trim();
            parent.appendChild(a);
        }));
        parser.addTag(new BBCodeTextTag('eicon', (p, parent, param, content) => {
            const img = p.createElement('img');
            img.src = `https://static.f-list.net/images/eicon/${content.trim().toLowerCase()}.gif`;
            img.className = 'eicon';
            parent.appendChild(img);
        }));
        return parser;
    }
    // -------------------------------------------------
    // END: F-List Parser Configuration
    // -------------------------------------------------

    function waitForElementAndRun() {
        const interval = setInterval(function() {
            if (document.getElementById('Content') && typeof unsafeWindow.FList !== 'undefined' && typeof unsafeWindow.FList.Inlines !== 'undefined') {
                clearInterval(interval);
                main();
            }
        }, 100);
    }

    function main() {
        // --- KEY CHANGE: Updated CSS ---
        GM_addStyle(`
            #Sidebar {
                width: 40px !important;
                min-width: 40px !important;
                padding: 0 !important;
            }
            #Content {
                display: flex;
                gap: 10px;
            }
            #editor-wrapper {
                flex: 1 1 50%;
                height: 85vh;
                overflow-y: auto;
                overflow-x: hidden; /* Prevent horizontal scrollbar */
                padding-bottom: 40px;
                box-sizing: border-box;
            }
            #editor-wrapper > form > table, #tabs {
                width: 100% !important;
                box-sizing: border-box;
            }
            #editor-wrapper .panel {
                margin-left: 0 !important;
                margin-right: 0 !important;
            }
            #live-preview-sidebar {
                flex: 1 1 50%;
                box-sizing: border-box;
                height: 85vh; /* Set a fixed height to prevent overlap */
                display: flex;
                flex-direction: column;
            }
            #live-preview-wrapper {
                flex: 1;
                display: flex;
                flex-direction: column;
                min-height: 0; /* Prevents flexbox overflow issues */
            }
            #live-preview-content.panel {
                flex: 1;
                overflow-y: auto;
                background-image: none !important;
                /* background-color is now set by JS for theme consistency */
                padding: 20px 5px; /* 20px top/bottom, 5px left/right */
                box-sizing: border-box;
            }
            #live-preview-content .character-description {
                line-height: 1.4;
                word-wrap: break-word;
            }
            #live-preview-content .CollapseBlock {
                background-color: #4C4646;
                padding: 10px;
                margin: 0;
            }
            #CharacterEditDescription {
                resize: vertical !important;
            }
        `);

        const contentCell = document.getElementById('Content');
        const originalSidebar = document.getElementById('Sidebar');
        if (!contentCell || !originalSidebar) return;

        // Remove the content from the original sidebar, keeping it as a decorative element
        originalSidebar.innerHTML = '';

        // Create a wrapper for the existing editor content
        const editorWrapper = document.createElement('div');
        editorWrapper.id = 'editor-wrapper';

        // --- KEY CHANGE: Create a new sidebar that wraps the preview ---
        const previewSidebarWrapper = document.createElement('div');
        previewSidebarWrapper.id = 'live-preview-sidebar';

        // Get theme colors
        const sidebarStyle = window.getComputedStyle(originalSidebar);
        const contentStyle = window.getComputedStyle(contentCell);
        const originalSidebarColor = sidebarStyle.backgroundColor;
        const contentBackgroundColor = contentStyle.backgroundColor;

        // Apply themed colors to the new sidebar
        previewSidebarWrapper.style.backgroundColor = contentBackgroundColor;
        previewSidebarWrapper.style.padding = '10px';
        previewSidebarWrapper.style.borderLeft = sidebarStyle.borderLeft;

        // This inner wrapper holds the preview content itself
        const previewWrapper = document.createElement('div');
        previewWrapper.id = 'live-preview-wrapper';
        previewWrapper.innerHTML = `<div id="live-preview-content" class="panel"><div class="character-description"></div></div>`;

        // Apply themed color to the inner panel
        const previewContentPanel = previewWrapper.querySelector('#live-preview-content');
        if (previewContentPanel) {
            previewContentPanel.style.backgroundColor = originalSidebarColor;
        }

        // Place the preview content inside the new styled sidebar
        previewSidebarWrapper.appendChild(previewWrapper);

        // Move all of the original editor content into its own wrapper
        while (contentCell.firstChild) {
            editorWrapper.appendChild(contentCell.firstChild);
        }

        // Add the editor and the new preview sidebar back to the main content area
        contentCell.appendChild(editorWrapper);
        contentCell.appendChild(previewSidebarWrapper);

        // --- KEY CHANGE: Make elements visible now that the layout is ready ---
        GM_addStyle('#Sidebar, #Content { visibility: visible; }');

        // Get references to the textarea and the preview display area
        const descriptionTextarea = document.getElementById('CharacterEditDescription');
        const previewContentDiv = document.querySelector('#live-preview-content .character-description');
        const parser = createFListParser();

        let previewTimeout;
        const updatePreview = () => {
            const bbcode = descriptionTextarea.value;
            if (!bbcode.trim()) {
                previewContentDiv.innerHTML = '<em>Start typing to see a preview...</em>';
                return;
            }
            try {
                const parsedElement = parser.parseEverything(bbcode);
                previewContentDiv.innerHTML = '';
                previewContentDiv.appendChild(parsedElement);
            } catch (e) {
                console.error("F-List Live Preview: Error during parsing.", e);
                previewContentDiv.innerHTML = `<em style="color: red;">Error parsing BBCode. See console for details.</em>`;
            }
        };

        const debouncedUpdatePreview = () => {
            clearTimeout(previewTimeout);
            previewTimeout = setTimeout(updatePreview, 300);
        };

        descriptionTextarea.addEventListener('input', debouncedUpdatePreview);
        updatePreview();

        console.log("F-List Live Preview script is active.");
    }

    waitForElementAndRun();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址