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;
+};