feat: add html editor for telegram messages

This commit is contained in:
2025-12-15 19:38:57 +03:00
parent 32ccb1ef43
commit 97df5b4c0a
11 changed files with 263 additions and 58 deletions

View File

@@ -8,6 +8,7 @@
"name": "admin",
"version": "0.0.0",
"dependencies": {
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@formkit/drag-and-drop": "^0.5.3",
@@ -572,6 +573,51 @@
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
@@ -1547,6 +1593,17 @@
"integrity": "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz",
"integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
@@ -1556,6 +1613,28 @@
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.12",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.12.tgz",
"integrity": "sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",

View File

@@ -16,6 +16,7 @@
"format": "prettier --write src/"
},
"dependencies": {
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@formkit/drag-and-drop": "^0.5.3",

View File

@@ -0,0 +1,133 @@
<template>
<div class="tw:space-y-2">
<textarea
ref="textareaRef"
class="form-control"
:placeholder="placeholder"
></textarea>
</div>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
const props = defineProps({
placeholder: {
type: String,
default: "",
},
height: {
type: Number,
default: 240,
},
});
const model = defineModel({
type: String,
default: "",
});
const textareaRef = ref(null);
const summernoteInstance = ref(null);
const getJQuery = () => window.$ || window.jQuery;
const normalizeTelegramHtml = (html = "") => {
const withoutEmptyParagraphs = html.replace(/<p><br><\/p>/gi, "<br>");
const withoutParagraphs = withoutEmptyParagraphs
.replace(/<p>/gi, "")
.replace(/<\/p>/gi, "<br>");
return withoutParagraphs.replace(/(?:<br>\s*)+$/i, "").trim();
};
const makeSpoilerButton = ($) => (context) => {
const ui = $.summernote.ui;
return ui
.button({
contents: '<i class="fa fa-eye-slash"></i>',
tooltip: "Спойлер (Telegram)",
click() {
const selectedText = context.invoke("editor.getSelectedText") || "";
const content = selectedText || "spoiler";
context.invoke(
"editor.pasteHTML",
`<span class="tg-spoiler">${content}</span>`
);
},
})
.render();
};
onMounted(() => {
const $ = getJQuery();
if (!$ || !textareaRef.value) {
console.warn("[RichTextEditor] jQuery или textarea недоступны");
return;
}
const $el = $(textareaRef.value);
$el.summernote({
height: props.height,
placeholder: props.placeholder,
shortcuts: false,
dialogsInBody: true,
disableResizeEditor: true,
buttons: {
spoiler: makeSpoilerButton($),
},
toolbar: [
["font", ["bold", "underline", "italic", "strikethrough", "clear"]],
["para", ["ul", "ol", "paragraph"]],
["insert", ["link", "spoiler"]],
["view", ["fullscreen", "codeview", "help"]],
],
callbacks: {
onChange(contents) {
const normalized = normalizeTelegramHtml(contents ?? "");
if (normalized !== contents) {
$el.summernote("code", normalized);
return;
}
model.value = normalized;
},
onKeydown(e) {
if (e.keyCode === 13) {
e.preventDefault();
$el.summernote("pasteHTML", "<br>");
}
},
},
});
if (model.value) {
$el.summernote("code", normalizeTelegramHtml(model.value));
}
summernoteInstance.value = $el;
});
watch(
model,
(value) => {
const instance = summernoteInstance.value;
if (!instance) {
return;
}
const normalized = normalizeTelegramHtml(value || "");
const current = normalizeTelegramHtml(instance.summernote("code"));
if (current !== normalized) {
instance.summernote("code", normalized);
}
}
);
onBeforeUnmount(() => {
if (summernoteInstance.value) {
summernoteInstance.value.summernote("destroy");
summernoteInstance.value = null;
}
});
</script>

View File

@@ -2,12 +2,11 @@
<SettingsItem :label="label">
<template #default>
<div style="margin-bottom: 10px;">
<textarea
<Codemirror
v-model="model"
:rows="rows"
:placeholder="placeholder"
class="form-control"
></textarea>
:extensions="extensions"
/>
</div>
<div>
@@ -37,7 +36,12 @@
<div class="collapse" :id="collapseId" style="margin-top: 15px">
<div class="well">
<p>Вы можете использовать переменные:</p>
<p>
Для формирования сообщения используется HTML разметка.
Telegram поддерживает только часть HTML тегов, которые описаны в их
<a href="https://core.telegram.org/bots/api#html-style" target="_blank">документации <i class="fa fa-external-link"></i></a>.
</p>
<p>Дополнительно к этому TeleCart добавляет переменные, которые вы можете использовать, чтобы сделать сообщения динамическими.</p>
<ul>
<li><code>{store_name}</code> название магазина</li>
<li><code>{order_id}</code> номер заказа</li>
@@ -50,19 +54,7 @@
<li><code>{ip}</code> IP покупателя</li>
<li><code>{created_at}</code> дата и время создания заказа</li>
</ul>
<p>
Форматирование: поддерживается
<a href="https://core.telegram.org/bots/api#markdownv2-style" target="_blank">
*MarkdownV2*
<i class="fa fa-external-link"></i>
</a>.
</p>
<p>Символы, которые нужно экранировать в тексте:</p>
<pre>_ * [ ] ( ) ~ ` > # + - = | { } . !</pre>
<p>
Каждый из них нужно экранировать обратным слэшем \, если он не используется для форматирования.
Например вместо <code>Заказ #123</code> нужно писать <code>Заказ \#123</code>.
</p>
<p></p>
</div>
</div>
</template>
@@ -78,11 +70,15 @@ import {useSettingsStore} from "@/stores/settings.js";
import {ref, toRaw, useId} from "vue";
import SettingsItem from "@/components/SettingsItem.vue";
import {apiPost} from "@/utils/http.js";
import {Codemirror} from "vue-codemirror";
import { html } from '@codemirror/lang-html';
import { oneDark } from '@codemirror/theme-one-dark';
const model = defineModel();
const settings = useSettingsStore();
const isSending = ref(false);
const collapseId = useId();
const extensions = [html(), oneDark];
const props = defineProps({
label: {