diff --git a/.gitignore b/.gitignore index ceaea36..36b4ef9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.idea + # ---> Node # Logs logs diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..a3c84f1 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,14 @@ +## Версия 0.2.0 () +* Перевод кода на TypeScript. +* + +## Версия 0.1.2 (22 апреля 2022 г.) +* Добавлены поле `editor` и методы `addEditorButton` и `generateEditorButtons`. + +## Версия 0.1.1 +* Добавлен метод `addAuthorEditor`. + +## Версия 0.1.0 +* Начальная версия. +* Обработка событий `pun_main_ready`, `pun_preview`, `pun_post`, `pun_edit`, `spoiler.firstOpen`. +* Методы `addAdapter`, `addTag`. diff --git a/README.md b/README.md index c1fa2b2..9994e52 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,16 @@ -# mybbcode +# MyBBCode + +Гибкий инструмент для работы с BB-кодами на форумах, основанных на платформе mybb.ru. + +# F.A.Q. + +## Зачем нужна эта утилита? +Недавние изменения на MyBB, в частности, добавление пользовательских тегов и ajax в форме ответа, несколько изменили методы работу с пользовательскими тегами: первое изменение упростило, второе же усложнило. Это, впрочем, нормально, но, по меньшей мере, у меня случилась некоторая фрустрация оттого, как в некоторых местах приходиться работать с некоторыми тегами. Фрустрацию я преодолел, написав прослойку, которая позволяет при помощи относительно короткого и простого кода добавить методы для модификации. + +## Что утилита делает? +Если простыми словами, то данный скрипт не делает ничего сам по себе, но он помогает облегчить жизнь тем, кто использует различные скрипты для модификации сообщений на форумах, обеспечивая простой доступ к +* обработке сообщений, в том числе загруженных через ajax, добавленных в предпросмотр и других местах, +* добавлению кнопок в форме ответа, а так же изменению порядка их отображения. + +И всё это с использованием последних новинок на сервисе MyBB, которые могут позволить минимизировать количество кода для реализации простых функций. -Гибкий инструмент для работы с BB-кодами на форумах, основанных на платформе mybb.ru. \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..70fe0da --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "mybbcode", + "version": "0.1.3", + "description": "Гибкий инструмент для работы с BB-кодами на форумах, основанных на платформе mybb.ru", + "main": "index.js", + "scripts": { + "build": "webpack --mode=production --node-env=production", + "build:dev": "webpack --mode=development", + "build:prod": "webpack --mode=production --node-env=production", + "watch": "webpack --watch" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/kozhilya/mybbcode.git" + }, + "keywords": [ + "mybb", + "bb", + "codes" + ], + "author": "kozhilya", + "license": "MIT", + "bugs": { + "url": "https://github.com/kozhilya/mybbcode/issues" + }, + "homepage": "https://github.com/kozhilya/mybbcode#readme", + "devDependencies": { + "@babel/cli": "^7.21.0", + "@babel/core": "^7.21.3", + "@babel/preset-env": "^7.20.2", + "@babel/preset-typescript": "^7.21.0", + "@types/jquery": "^3.5.29", + "@webpack-cli/generators": "^3.0.1", + "babelify": "^10.0.0", + "grunt": "^1.6.1", + "grunt-browserify": "^6.0.0", + "grunt-contrib-uglify": "^5.2.2", + "grunt-jsdoc-to-markdown": "^6.0.0", + "html-loader": "^4.2.0", + "jsdoc-babel": "^0.5.0", + "jsdoc-to-markdown": "^8.0.0", + "markdown-it": "^13.0.1", + "markdown-it-container": "^3.0.0", + "prettier": "^2.8.2", + "terser-webpack-plugin": "^5.3.7", + "ts-loader": "^9.4.2", + "tsify": "^5.0.4", + "typescript": "^5.3.3", + "uglify-js": "^3.17.4", + "webpack": "^5.76.3", + "webpack-cli": "^5.0.1" + }, + "dependencies": { + "webpack-utf8-bom": "^1.4.0" + } +} diff --git a/src/editor.ts b/src/editor.ts new file mode 100644 index 0000000..c689d0a --- /dev/null +++ b/src/editor.ts @@ -0,0 +1,31 @@ +export class Editor { + /** + * Порядок отображения кнопок формы ответа. + */ + buttons: string[] = null; + + /** + * Массив из 2 значений, которые являются HTML-кодами, что будут добавлены до и после списка кнопок. + * @type {string[]} + */ + wrapper: string[] = ['', '
']; + + /** + * Метод для генерации кнопок. + * @param {string} key Название кода, ключ объекта `FORUM.editor`. + * @param value Набор правил кода, соответствующее ключу key значения объекта `FORUM.editor[key]`. + */ + generator(key: string, value: any): string { + return ` `; + } + + /** + * Дополнительные элементы, которые могут быть добавлены наряду с другими формы ответа + * @type {Object.} + */ + specials: {[key: string] : string} = { + '|': ' ', + '~': ' ', + '\n': '', + }; +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..2c0fc30 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,5 @@ +import MybbCode from "./mybb-code"; + +(window as any).MyBBCode = (window as any).MyBBCode || (() => { + return new MybbCode(); +})(); \ No newline at end of file diff --git a/src/mybb-code.ts b/src/mybb-code.ts new file mode 100644 index 0000000..bc9c652 --- /dev/null +++ b/src/mybb-code.ts @@ -0,0 +1,260 @@ +import {AdapterCallback, AuthorEditorCallback, EventResolver, TagCallback} from "./types"; +import {FORUM, MybbEditorTag} from "./mybb-editor"; +import {Editor} from "./editor"; + +export default class MybbCode { + /** + * Текст, который будет установлен при вызове `set_html(false)` в обработчиках тегов, + * добавленных при помощи метода `addTag`. + * @type loadingText {string} + * @see addTag + */ + loadingText = '\u0421\u043E\u0434\u0435\u0440\u0436\u0438\u043C\u043E\u0435 \u0437\u0430\u0433\u0440\u0443\u0436\u0430\u0435\u0442\u0441\u044F...'; + + /** + * Набор тегов + */ + private tags: {[key: string]: TagCallback} = {}; + + /** + * Набор адаптеров + */ + private adapters: AdapterCallback[] = []; + + /** + * Набор обработчиков столбца автора + */ + private authorEditors: AuthorEditorCallback[] = []; + + /** + * Выделение контейнеров сообщений для событий + */ + events: {[key: string]: EventResolver} = { + 'pun_preview': (_) => $('#post-preview .post-content'), + 'pun_post': (_) => $('.topic .new-ajax .post-content'), + 'pun_edit': (e) => $(e.sender), + 'pun_main_ready': (_) => $('.post-content'), + 'spoiler.firstOpen': (e) => $(e.sender).parent().find('blockquote').eq(0) + }; + + /** + * Набор правил для генерации кнопок формы ответа. Удаление полей настоятельно не рекомендуется. + */ + editor: Editor = new Editor(); + + /** + * Конструктор класса `MyBBCode`. + */ + constructor() { + $(document).on(Object.keys(this.events).join(' '), (event) => { + const blocks = this.events[event.type](event); + + this.processPostContents(blocks); + }); + + if (FORUM.editor) { + this.editor.buttons = Object.keys(FORUM.editor).filter((v) => v !== 'settings'); + $(document).on('pun_main_ready', () => this.generateEditorButtons()); + } + } + + /** + * Добавление нового события, который будет запускать обработчики сообщений и обработчики пользовательских кодов. + * @param {string} name Отслеживаемое DOM-событие, используемое методом `jQuery.on(name)`, и которое можно + * вызвать методом `jQuery.trigger(name)`. + * @param {EventResolver} resolver Это должна быть функция, которые на основе объекта события (`event`, он + * подаётся на вход этой функции), определяет, какие контейнеры должны быть + * обработаны обработчиками. + */ + registerEvent(name: string, resolver: EventResolver): void { + $(document).on(name, (event) => this.processPostContents(resolver(event))); + } + + /** + * Установка HTML загружающегося тега. + * @param {JQuery} elem Цель установки + * @param {string|false} html HTML-код для установки + */ + private setHtml(elem: JQuery, html: string|false): void { + if (html === false) { + elem.html(this.loadingText).addClass('loading'); + } + else { + elem.html(html).removeClass('loading'); + } + } + + /** + * Обработка контейнера сообщения всеми зарегистрированными обработчиками. + * @param containers jQuery-объект, ссылающийся на контейнер (или контейнеры), который содержит необработанное сообщение. + */ + private processPostContents(containers: JQuery): void { + containers.each((_, container) => { + const $container = $(container); + + $.each(this.tags, (tag, action) => { + $('.custom_tag_' + tag, $container).each((i, elem) => { + const $elem = $(elem); + + let id = $elem.prop('id') || (tag + '-' + i); + + if ($elem.hasClass('processed')) { + return; + } + + action($elem, '.custom_tag_' + tag + '#' + id, this.setHtml.bind(this, $elem)); + + $elem.addClass('processed'); + }); + }); + + $.each(this.adapters, (i, action) => { + action($container); + }); + + if ($container.hasClass('post-content')) { + const ul = $container.parents('.post').find('.post-author ul'); + + if (!ul.hasClass('mybbcode-processed')) { + $.each(this.authorEditors, (i, action) => { + action(ul); + }); + + ul.addClass('mybbcode-processed') + } + } + }); + } + + /** + * Добавление обработчиков пользовательских тегов, добавленных через систему пользовательских тегов + * (/admin_forms.php — "Пользовательские bb-теги"). Для корректной работы обработчика рекомендуется + * (но НЕ необходимо) указать для тега флаг `u` (unique). + * @param {string} name Строка, название тега. Должно совпадать с названием тега в поле "Пользовательские bb-теги". + * @param {TagCallback} callback Функция, которая будет вызываться по событию. + */ + addTag(name: string, callback: TagCallback): void { + if (this.tags[name]) { + console.warn('[MyBBCode] Tag "' + name + '" declared more than one time!'); + } + + this.tags[name] = callback; + } + + /** + * Добавление общих обработчиков, которые будут применяться к сообщениям. + * @param {AdapterCallback} callback + */ + addAdapter(callback: AdapterCallback): void { + this.adapters.push(callback); + } + + /** + * Добавление обработчика, изменяющего столбец автора сообщения. + * После обработки, к ul-элементу будет добавлен класс `mybbcode-processed`; если элемент имеет этот класс, + * обработчик не будет применён. + * @param {AuthorEditorCallback} callback + */ + addAuthorEditor(callback: AuthorEditorCallback): void { + this.authorEditors.push(callback); + } + + /** + * Сгенерировать кнопку + * @param {string} key id кнопки + * @returns {string} Код кнопки + */ + private generateEditorButton(key: string): string { + if (!FORUM.editor) { + return ''; + } + + if (key in this.editor.specials) { + return this.editor.specials[key] + } + + if (key in FORUM.editor) { + const value = FORUM.editor[key]; + return this.editor.generator(key, value); + } + + return ''; + } + + /** + * Сгенерировать заново строку с кнопками редактора. + */ + generateEditorButtons(): void { + if (!FORUM.editor || (this.editor.buttons === null)) { + return; + } + + const old = Object.keys(FORUM.editor).filter((v) => v !== 'settings'); + let changed = old.length !== this.editor.buttons.length; + for (let i = 0; !changed && (i < old.length); i++) { + changed = changed || (old[i] !== this.editor.buttons[i]); + } + + if (!changed) { + return; + } + + let result = this.editor.wrapper[0]; + + for (const key of this.editor.buttons) { + result += this.generateEditorButton(key); + } + + result += this.editor.wrapper[1]; + + $('#form-buttons').empty().html(result); + } + + /** + * Добавление кнопки формы. + * После объявления при помощи этого метода, объект `data` будет доступен после объявления как `FORUM.editor.key`. + * @param {string} key Идентификатор кнопки, который будет добавлен к `td.button#button-key`. + * @param {MybbEditorTag} data Данные поля, которые будут использоваться стандартными скриптами. + * Этому объектов можно указать и другие поля, однако указанные обязательны. + * @param {string} data.name Строка, название кнопки. + * @param {function} data.onclick Функция, которая будет вызвана при нажатии кнопки. + * @param {number|string|false} [before] Указание, перед каким элементом необходимо добавить кнопку. Допустимые значения: + * Число `i` — тогда кнопка будет добавлена перед `i`-м элементом массива `MyBBCode.editor.buttons`. + * Строкой-идентификатором кнопки `key` — тогда новая кнопка будет помещена перед кнопкой с идентификатором `key`. + */ + addEditorButton(key: string, data: MybbEditorTag, before: number|string|false = false): void { + if (!('FORUM' in window) || !('editor' in FORUM)) { + return null; + } + + if (!(key in this.editor.specials)) { + FORUM.editor[key] = data; + } + + if (this.editor.buttons !== null) { + let index: number|false; + + if (before === false) { + index = false; + } + else if (typeof before === "string") { + index = this.editor.buttons.indexOf(before); + index = (index < 0) ? false : index; + } + else if ((typeof before === "number") && (before >= 0) && (before < this.editor.buttons.length)) { + index = before; + } + else { + index = false; + } + + if (index === false) { + this.editor.buttons.push(key); + } + else { + this.editor.buttons.splice(index, 0, key); + } + } + } + +} \ No newline at end of file diff --git a/src/mybb-editor.ts b/src/mybb-editor.ts new file mode 100644 index 0000000..7136d25 --- /dev/null +++ b/src/mybb-editor.ts @@ -0,0 +1,66 @@ +declare module 'FORUM' { + let FORUM: { + editor: MybbEditor + } + + export class MybbEditorTag { + /** + * @param {string} name Строка, название кнопки. + */ + name: string; + + /** + * @param {function} onclick Функция, которая будет вызвана при нажатии кнопки. + */ + onclick: () => void; + } + + export type MybbEditorSettings = { + autofocus: string + } + + export type MybbEditor = { + settings: MybbEditorSettings; + [key: string]: MybbEditorTag | MybbEditorSettings; + } + + export function quote(e: string, t: string, i: string): void; + + export function bbcode(tagStart: string, tagEnd: string): void; + + export function changeVisibility(areaName: string): void; + + export function insert(value: string): void; + + export function smile(emote: string): void; + + export function tag_table(rows: number, columns: number): void; + + export function MYBB_vsi(element: HTMLElement, event: any): void; + + export function insertUploadedImage(element: HTMLElement | JQuery): void; + + export function tag_spoiler(spoilerType: string): void; + + export function keyboard(character: string): void; + + export function to(target: string): void; + + export class MYBB_vsc { + cH: any; + RG: { [key: string]: MYBB_vsc_item }; + checkHost: (hostingItem: MYBB_vsc_item, input: string) => boolean; + clickHost: (element: HTMLAnchorElement) => void; + parse: () => void; + isLink: (input: string) => boolean; + message: (html: string) => void; + reInit: () => void; + } + + type MYBB_vsc_item = { + l: "0" | "1"; + t: string; + x: RegExp; + nf: string; + } +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..aae2e41 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,10 @@ + +export type AdapterCallback = (container: JQuery) => void; + +export type TagCallback = (elem: JQuery, + selector: string, + set_html: string | boolean) => void; + +export type AuthorEditorCallback = (container: JQuery) => void; + +export type EventResolver = (e: any) => JQuery \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..05e45d3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "noImplicitAny": true, + "module": "es6", + "target": "es2017", + "allowJs": true, + "declaration": true, + "moduleResolution": "node", + "esModuleInterop": true, + "typeRoots": [ + "./node_modules/@types", + "./src/types" + ] + }, + "files": [ + "src/main.ts" + ] +} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..1d7ee85 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,77 @@ +const path = require("path"); +const webpack = require("webpack"); +const BomPlugin = require("webpack-utf8-bom"); +const { exec } = require("node:child_process"); +const TerserPlugin = require("terser-webpack-plugin"); + +const isProduction = process.env.NODE_ENV === "production"; + +const config = { + entry: "./src/main.ts", + output: { + path: path.resolve(__dirname, "dist"), + filename: () => "mybbcode" + (isProduction ? ".min" : "") + ".js", + }, + plugins: [ + new BomPlugin(true), + { + apply: (compiler) => { + compiler.hooks.afterEmit.tap("AfterEmitPlugin", (compilation) => { + exec("./upload.sh", (err, stdout, stderr) => { + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + console.log("Uploaded."); + }); + }); + }, + }, + // Add your plugins here + // Learn more about plugins from https://webpack.js.org/configuration/plugins/ + ], + module: { + rules: [ + { + test: /\.(ts|tsx)$/i, + loader: "ts-loader", + exclude: ["/node_modules/"], + }, + { + test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i, + type: "asset", + }, + { + test: /\.html$/i, + loader: "html-loader", + }, + // Add your rules for custom modules here + // Learn more about loaders from https://webpack.js.org/loaders/ + ], + }, + resolve: { + extensions: [".tsx", ".ts", ".jsx", ".js", "..."], + }, + externals: { + jquery: "jQuery", + }, + watchOptions: { + aggregateTimeout: 20, + }, + optimization: { + minimize: isProduction, + minimizer: [new TerserPlugin({ + terserOptions: { + compress: true, + }, + minify: TerserPlugin.uglifyJsMinify, + })], + }, +}; + +module.exports = () => { + if (isProduction) { + config.mode = "production"; + } else { + config.mode = "development"; + } + return config; +};