// ==UserScript==
// @name F-List Live Profile Preview (Parser Version)
// @namespace http://tampermonkey.net/
// @version 3.4
// @description Adds a live side-panel preview when editing a character profile on F-List using a native parser.
// @author Gemini
// @match *://*.f-list.net/character_edit.php*
// @grant GM_addStyle
// @grant unsafeWindow
// @require https://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min.js
// ==/UserScript==
(function() {
'use strict';
/* global $, unsafeWindow, GM_addStyle */
// -------------------------------------------------
// START: BBCode Parser
// -------------------------------------------------
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, []); // Text tags cannot have child tags
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;
const selfAllowed = self !== undefined ? isAllowed(self.tag) : true;
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];
const allowed = isAllowed(tagKey);
if (!close) {
if (parent !== undefined) {
appendTextWithLineBreaks(parent, input.substring(mark, tagStart));
}
mark = i + 1;
if (!allowed || 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
// -------------------------------------------------
function createFListParser() {
const parser = new BBCodeParser();
// Simple formatting tags
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'));
// Horizontal Rule
const hrTag = new BBCodeSimpleTag('hr', 'hr');
hrTag.noClosingTag = true;
parser.addTag(hrTag);
// Alignment tags
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;
}));
// Color tag
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;
}));
// URL tag
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]`);
}
}));
// Image tag
parser.addTag(new BBCodeTextTag('img', (p, parent, param, content) => {
const img = p.createElement('img');
img.style.maxWidth = '100%';
if (param) { // [img=ID]...[/img] case
const inlines = unsafeWindow.FList.Inlines.inlines;
const inlineData = inlines ? inlines[param] : null;
if (inlineData) {
const { hash, extension } = inlineData;
// THE FIX IS HERE: Use substring(2, 4) instead of substring(2, 2)
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 { // [img]URL[/img] case
const url = content.trim();
if (url.startsWith('http://') || url.startsWith('https://')) {
img.src = url;
} else {
appendTextWithLineBreaks(parent, `[img]${content}[/img]`);
return;
}
}
parent.appendChild(img);
}));
// Icon / User tags
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()}.png`;
img.style.width = '50px';
img.style.height = '50px';
img.style.verticalAlign = 'middle';
img.style.marginRight = '5px';
a.appendChild(img);
appendTextWithLineBreaks(a, name);
parent.appendChild(a);
};
parser.addTag(new BBCodeTextTag('icon', createUserTag));
parser.addTag(new BBCodeTextTag('user', createUserTag));
// Collapse tag
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');
// Use a non-breaking space for blank titles to ensure the header has height
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() {
$(block).slideToggle(200);
});
return block; // Children will be parsed into this block
}));
return parser;
}
// -------------------------------------------------
// END: F-List Parser Configuration
// -------------------------------------------------
function waitForElementAndRun() {
console.log("F-List Live Preview: Waiting for editor and inline data...");
const interval = setInterval(function() {
if (document.getElementById('CharacterEditDescription') && typeof unsafeWindow.FList !== 'undefined' && typeof unsafeWindow.FList.Inlines !== 'undefined') {
clearInterval(interval);
console.log("F-List Live Preview: Editor and all data ready! Initializing.");
main();
}
}, 100);
}
function main() {
GM_addStyle(`
#Page > tbody > tr { display: flex; width: 100%; }
#Content { flex: 1 1 50%; min-width: 600px; }
#live-preview-container { flex: 1 1 50%; padding: 0 10px 10px 10px; vertical-align: top; }
#live-preview-content { background: #1c1c1c; border: 1px solid #444; min-height: 500px; height: 80vh; overflow-y: auto; padding: 10px; }
#live-preview-content .character-description { line-height: 1.4; color: #ccc; word-wrap: break-word; }
/* F-list specific styles for parser output */
#live-preview-content blockquote { border-left: 3px solid #555; background: #2e2e2e; padding: 5px 10px; margin: 8px 0; }
#live-preview-content .CollapseHeader { background: #333; border: 1px solid #555; padding: 4px 8px; margin-top: 5px; cursor: pointer; user-select: none; }
#live-preview-content .CollapseHeader:hover { background: #444; }
#live-preview-content .CollapseHeaderText { font-weight: bold; }
#live-preview-content .CollapseBlock { border: 1px solid #555; border-top: 0; padding: 8px; background: #292929; }
`);
const contentCell = document.getElementById('Content');
if (!contentCell) return;
const previewContainer = document.createElement('td');
previewContainer.id = 'live-preview-container';
previewContainer.innerHTML = `<h2 style="margin:10px 0px 20px 0px;">Live Preview</h2><div id="live-preview-content"><div class="character-description"></div></div>`;
contentCell.parentNode.insertBefore(previewContainer, contentCell.nextSibling);
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 = ''; // Clear previous content
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(); // Initial preview on page load
console.log("F-List Live Preview: Native parser setup complete and active.");
}
waitForElementAndRun();
})();