Современный BB+HTML редактор для материалов uCoz без плагинов и сторонних библиотек

Юрий Герук 2025-12-26 53
Современный BB+HTML редактор для материалов uCoz без плагинов и сторонних библиотек

Введение

BB редактор в uCoz функционально рабочий, но по ощущениям устарел. При регулярной работе с материалами это быстро начинает мешать. Кнопки выглядят разрозненно, фокус может теряться при нажатии, выделение текста иногда сбивается, а вставка текста из Word или Google Docs почти всегда приносит в поле лишний мусор.

В итоге вместо спокойной работы с контентом приходится постоянно исправлять форматирование и следить, чтобы редактор не вел себя странно.

В этой статье разобрано практическое улучшение BB редактора uCoz, реализованное исключительно с помощью CSS и JavaScript:

  • Без плагинов
  • Без внешних библиотек
  • Без вмешательства в ядро системы

Решение устанавливается точечно и аккуратно улучшает стандартный редактор, не ломая его логику и не создавая проблем при обновлениях.

Что делает это улучшение

Скрипт не заменяет редактор и не переписывает его логику. Он работает поверх стандартного BB+HTML редактора uCoz и решает конкретные проблемы:

  • Приводит панель редактора к аккуратному и современному виду.
  • Делает кнопки единообразными и предсказуемыми.
  • Полностью устраняет потерю выделения текста при нажатии кнопок.
  • Корректно заворачивает выделенный текст в BB теги.
  • Добавляет недостающие кнопки форматирования.
  • Подсвечивает активные теги, если курсор находится внутри них.
  • Добавляет уведомления о действиях через аккуратные toast сообщения.
  • Очищает вставку из буфера от типографского мусора.
  • Позволяет очистить форматирование выделенного фрагмента.
  • Поддерживает горячие клавиши для быстрого форматирования.
  • Корректно работает в светлой и тёмной теме браузера.

Редактор остаётся стандартным uCoz, но пользоваться им становится заметно комфортнее.

Какие возможности добавляются

После установки в редакторе появляются дополнительные инструменты:

  • Зачеркнутый текст.
  • Надчеркнутый текст.
  • Горизонтальная линия.
  • Цитата с корректными переносами строк.
  • Блок кода.
  • Спойлер.
  • Список с готовым шаблоном пунктов.
  • Быстрая вставка ссылки.
  • Очистка форматирования выделенного текста.

Также работают горячие клавиши:

  • Ctrl B — жирный текст.
  • Ctrl I — курсив.
  • Ctrl U — подчёркивание.
  • Ctrl Shift C — блок кода.
  • Ctrl K — вставка ссылки.

Где и как устанавливать код в uCoz

Это принципиально важный момент. Код не нужно вставлять в глобальные шаблоны сайта. Он устанавливается только в шаблоны форм, где используется редактор материалов.

Правильный порядок установки:

  • Открой Панель управления сайтом.
  • Перейди в раздел Управление дизайном.
  • Выбери модуль, для которого нужно улучшить редактор. Например Каталог статей или Новости.
  • Открой шаблон Форма добавления и редактирования материала и вставь код в самый низ шаблона.
  • Сохрани изменения.

Если редактор используется в нескольких модулях, код нужно добавить в каждый модуль отдельно, так как формы добавления и редактирования у uCoz независимые.

Полный рабочий код улучшенного BB+HTML редактора

Ниже приведён оригинальный код без каких-либо изменений.
Его необходимо вставлять целиком, без удаления и переписывания частей:

<style>
/* Панели управления */
.ucoz-editor-panel{
  display:none;
  background:#ffffff!important;
  border:1px solid #e2e8f0!important;
  border-radius:12px 12px 0 0!important;
  padding:8px 10px!important;
  flex-wrap:wrap!important;
  gap:4px!important;
  align-items:center!important;
  margin-bottom:-1px!important;
  position:relative;
  z-index:100;
}

.ucoz-editor-panel[style*="display: block"],
.ucoz-editor-panel[style=""]{
  display:flex!important;
}

/* Кнопки */
.ucoz-editor-panel .codeButtons,
.ucoz-editor-panel button.custom-bb{
  background:#ffffff!important;
  border:1px solid #e2e8f0!important;
  border-radius:8px!important;
  color:#475569!important;
  height:30px!important;
  min-width:32px;
  padding:0 6px!important;
  font-size:12px!important;
  cursor:pointer;
  display:inline-flex;
  align-items:center;
  justify-content:center;
  transition:background .2s, border-color .2s, box-shadow .2s, transform .05s ease;
  margin:0!important;
  user-select:none;
  line-height:1;
}

.ucoz-editor-panel .codeButtons:hover,
.ucoz-editor-panel button.custom-bb:hover{
  background:#f8fafc!important;
  border-color:#3b82f6!important;
}

.ucoz-editor-panel .codeButtons:active,
.ucoz-editor-panel button.custom-bb:active{
  transform:translateY(1px);
}

.ucoz-editor-panel .codeButtons:focus-visible,
.ucoz-editor-panel button.custom-bb:focus-visible{
  outline:none;
  box-shadow:0 0 0 3px rgba(59,130,246,.25);
  border-color:#3b82f6!important;
}

/* Активная кнопка */
.ucoz-editor-panel .codeButtons.is-active,
.ucoz-editor-panel button.custom-bb.is-active{
  background:#eff6ff!important;
  border-color:#3b82f6!important;
  color:#1d4ed8!important;
}

/* Очистка мусора uCoz */
.ucoz-editor-panel br,
.ucoz-editor-panel span[id*="bc"] br{
  display:none!important;
}
.ucoz-editor-panel span[id*="bc"]{
  display:contents!important;
}

/* Поле */
textarea.manFl{
  border-radius:0 0 12px 12px!important;
  border:1px solid #e2e8f0!important;
  padding:15px!important;
}

textarea.manFl:focus-visible{
  outline:none;
  box-shadow:0 0 0 3px rgba(59,130,246,.20);
  border-color:#3b82f6!important;
}

/* Тост */
.ucoz-editor-toast{
  position:fixed;
  left:50%;
  bottom:16px;
  transform:translateX(-50%);
  background:rgba(15,23,42,.92);
  color:#fff;
  padding:10px 12px;
  border-radius:12px;
  font-size:13px;
  line-height:1.2;
  box-shadow:0 10px 30px rgba(0,0,0,.25);
  opacity:0;
  pointer-events:none;
  transition:opacity .2s, transform .2s;
  z-index:999999;
}
.ucoz-editor-toast.is-show{
  opacity:1;
  transform:translateX(-50%) translateY(-6px);
}

/* Dark mode */
@media (prefers-color-scheme: dark){
  .ucoz-editor-panel{
    background:#0b1220!important;
    border-color:#223047!important;
  }
  .ucoz-editor-panel .codeButtons,
  .ucoz-editor-panel button.custom-bb{
    background:#0b1220!important;
    border-color:#223047!important;
    color:#cbd5e1!important;
  }
  .ucoz-editor-panel .codeButtons:hover,
  .ucoz-editor-panel button.custom-bb:hover{
    background:#0f1a2e!important;
    border-color:#60a5fa!important;
  }
  textarea.manFl{
    background:#0b1220!important;
    color:#e2e8f0!important;
    border-color:#223047!important;
  }
}
</style>

<script>
(function () {
  const iconMap = {
    b: "<b>B</b>",
    i: "<i>I</i>",
    u: "<u>U</u>",
    s: "<s>S</s>",
    o: '<span style="text-decoration:overline">O</span>',
    hr: "—",
    quote: "❞",
    code: "‹/›",
    spoiler: "👁️",
    img: "🖼️",
    list: "•≡",
    hide: "🔒",
    clear: "🧽",
    http: "🔗"
  };

  function toast(msg){
    let el = document.getElementById("ucozEditorToast");
    if (!el){
      el = document.createElement("div");
      el.className = "ucoz-editor-toast";
      el.id = "ucozEditorToast";
      document.body.appendChild(el);
    }
    el.textContent = msg;
    el.classList.add("is-show");
    clearTimeout(el._t);
    el._t = setTimeout(()=>el.classList.remove("is-show"), 1200);
  }

  function getActiveTextarea(){
    const a = document.activeElement;
    return (a && a.tagName === "TEXTAREA") ? a : null;
  }

  function getTargetTextarea(panel, targetId){
    const active = getActiveTextarea();
    if (active) return active;

    const byId = document.getElementById(targetId);
    if (byId && byId.tagName === "TEXTAREA") return byId;

    const byName = document.querySelector('textarea[name="' + CSS.escape(targetId) + '"]');
    if (byName) return byName;

    const wrap = panel.closest(".ucoz-editor-wrap, .comm-form-box, form, .ucoz-editor") || panel.parentElement;
    if (wrap){
      const near = wrap.querySelector("textarea");
      if (near) return near;
    }
    return null;
  }

  function getSel(area){
    const val = area.value || "";
    const selStart = typeof area.selectionStart === "number" ? area.selectionStart : val.length;
    const selEnd = typeof area.selectionEnd === "number" ? area.selectionEnd : val.length;
    return { val, selStart, selEnd, selectedText: val.substring(selStart, selEnd) };
  }

  function setSel(area, start, end){
    try{ area.setSelectionRange(start, end); }catch(e){}
  }

  function wrapSelection(area, start, end, opts){
    if (!area) return;

    const o = opts || {};
    const { val, selStart, selEnd, selectedText } = getSel(area);

    const replacement = start + selectedText + end;
    area.value = val.substring(0, selStart) + replacement + val.substring(selEnd);
    area.focus();

    if (selectedText.length){
      const newStart = selStart + start.length;
      const newEnd = newStart + selectedText.length;
      setSel(area, newStart, newEnd);
    } else {
      const pos = selStart + start.length + (o.cursorOffset || 0);
      setSel(area, pos, pos);
    }
  }

  function insertSmart(tag, area){
    const hasSelection = (getSel(area).selectedText || "").length > 0;

    if (tag === "hr"){
      const { val, selStart, selEnd } = getSel(area);
      const ins = "[hr]";
      area.value = val.substring(0, selStart) + ins + val.substring(selEnd);
      area.focus();
      setSel(area, selStart + ins.length, selStart + ins.length);
      return;
    }

    if (tag === "list" && !hasSelection){
      const tpl = "[list]\n[*]Пункт 1\n[*]Пункт 2\n[/list]";
      const { val, selStart, selEnd } = getSel(area);
      area.value = val.substring(0, selStart) + tpl + val.substring(selEnd);
      area.focus();
      const cursor = selStart + "[list]\n[*]".length;
      setSel(area, cursor, cursor + "Пункт 1".length);
      return;
    }

    if (tag === "code" && !hasSelection){
      wrapSelection(area, "[code]\n", "\n[/code]", { cursorOffset: 0 });
      return;
    }

    if (tag === "quote" && !hasSelection){
      wrapSelection(area, "[quote]\n", "\n[/quote]", { cursorOffset: 0 });
      return;
    }

    if (tag === "spoiler" && !hasSelection){
      wrapSelection(area, "[spoiler]\n", "\n[/spoiler]", { cursorOffset: 0 });
      return;
    }

    if (tag === "http"){
      const { selectedText } = getSel(area);
      const url = prompt("Ссылка");
      if (!url) return;

      if (selectedText){
        wrapSelection(area, "[url=" + url + "]", "[/url]", { cursorOffset: 0 });
      } else {
        const title = prompt("Текст ссылки") || url;
        const { val, selStart, selEnd } = getSel(area);
        const ins = "[url=" + url + "]" + title + "[/url]";
        area.value = val.substring(0, selStart) + ins + val.substring(selEnd);
        area.focus();
        const a = selStart + ("[url=" + url + "]").length;
        setSel(area, a, a + title.length);
      }
      return;
    }

    wrapSelection(area, "[" + tag + "]", "[/" + tag + "]", { cursorOffset: 0 });
  }

  function cleanFormatting(area){
    const { val, selStart, selEnd, selectedText } = getSel(area);
    if (!selectedText){
      toast("Нечего чистить");
      return;
    }

    let t = selectedText;

    t = t.replace(/\[\/?(b|i|u|s|o|quote|code|spoiler|hide|url)(=[^\]]+)?\]/gi, "");
    t = t.replace(/\[hr\]/gi, "");
    t = t.replace(/\[list\]|\[\/list\]|\[\*\]/gi, "");
    t = t.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n");

    area.value = val.substring(0, selStart) + t + val.substring(selEnd);
    area.focus();
    setSel(area, selStart, selStart + t.length);
    toast("Форматирование очищено");
  }

  function isCursorInsideTag(area, tag){
    const val = area.value || "";
    const pos = typeof area.selectionStart === "number" ? area.selectionStart : val.length;

    const open = "[" + tag + "]";
    const close = "[/" + tag + "]";

    const left = val.lastIndexOf(open, pos);
    if (left === -1) return false;

    const rightClose = val.indexOf(close, left + open.length);
    if (rightClose === -1) return false;

    return pos > left + open.length && pos <= rightClose;
  }

  function updateActiveButtons(panel, area){
    if (!panel || !area) return;

    const toCheck = ["b","i","u","s","o","quote","code","spoiler"];
    toCheck.forEach(t=>{
      const btn = panel.querySelector('[data-tag="' + t + '"]');
      if (!btn) return;
      const on = isCursorInsideTag(area, t);
      if (on) btn.classList.add("is-active");
      else btn.classList.remove("is-active");
    });
  }

  function cleanPastedText(text){
    if (!text) return text;
    return text
      .replace(/\u00A0/g, " ")
      .replace(/[“”]/g, '"')
      .replace(/[‘’]/g, "'")
      .replace(/\u2013|\u2014/g, "-")
      .replace(/\r\n/g, "\n")
      .replace(/\r/g, "\n");
  }

  function bindTextarea(area, panel){
    if (!area || area.dataset.ucozEditorBound) return;
    area.dataset.ucozEditorBound = "1";

    const onUpdate = () => updateActiveButtons(panel, area);

    area.addEventListener("keyup", onUpdate, true);
    area.addEventListener("mouseup", onUpdate, true);
    area.addEventListener("focus", onUpdate, true);

    area.addEventListener("paste", (e)=>{
      try{
        const clip = e.clipboardData && e.clipboardData.getData("text/plain");
        if (!clip) return;
        e.preventDefault();

        const cleaned = cleanPastedText(clip);
        const { val, selStart, selEnd } = getSel(area);

        area.value = val.substring(0, selStart) + cleaned + val.substring(selEnd);
        const newPos = selStart + cleaned.length;
        area.focus();
        setSel(area, newPos, newPos);
        onUpdate();
        toast("Вставка очищена");
      } catch (err) {}
    }, true);
  }

  function fire(panel, targetId, tag){
    const area = getTargetTextarea(panel, targetId);
    if (!area) return;

    if (tag === "clear"){
      cleanFormatting(area);
      updateActiveButtons(panel, area);
      return;
    }

    insertSmart(tag, area);
    updateActiveButtons(panel, area);
  }

  function handleHotkeys(e, panel, targetId){
    if (!(e.ctrlKey || e.metaKey)) return;

    const k = (e.key || "").toLowerCase();

    if (k === "b"){ e.preventDefault(); fire(panel, targetId, "b"); return; }
    if (k === "i"){ e.preventDefault(); fire(panel, targetId, "i"); return; }
    if (k === "u"){ e.preventDefault(); fire(panel, targetId, "u"); return; }
    if (k === "k"){ e.preventDefault(); fire(panel, targetId, "http"); return; }
    if (k === "c" && e.shiftKey){ e.preventDefault(); fire(panel, targetId, "code"); return; }
  }

  function process(panel){
    if (panel.dataset.working) return;
    panel.dataset.working = "true";

    const targetId = panel.id && panel.id.includes("brief") ? "brief" : "message";

    panel.querySelectorAll(".codeButtons").forEach((el)=>{
      if (el.tagName === "INPUT" && el.type === "button"){
        const btn = document.createElement("button");
        btn.type = "button";
        btn.className = el.className;
        btn.title = el.title || "";
        btn.setAttribute("aria-label", btn.title || "button");

        btn.onmousedown = (e)=>{
          e.preventDefault();
          if (typeof el.onclick === "function"){
            try { el.onclick(); } catch (err) {}
          }
        };

        btn.innerHTML = iconMap[el.value] || el.value || el.innerHTML;
        el.replaceWith(btn);
      }

      const html = (el.innerHTML || "").trim();
      const val = (el.value || "").trim();
      if (html === "···" || val === "···"){
        if ((el.id && el.id.includes("cdl")) || el.name === "left") el.innerHTML = "◀";
        if ((el.id && el.id.includes("cdc")) || el.name === "center") el.innerHTML = "◀▶";
        if ((el.id && el.id.includes("cdr")) || el.name === "right") el.innerHTML = "▶";
      }
    });

    const custom = [
      { t:"s", n:"Зачеркнутый" },
      { t:"o", n:"Надчеркнутый" },
      { t:"hr", n:"Линия" },
      { t:"quote", n:"Цитата" },
      { t:"code", n:"Код" },
      { t:"spoiler", n:"Спойлер" },
      { t:"list", n:"Список" },
      { t:"http", n:"Ссылка" },
      { t:"clear", n:"Очистить форматирование" }
    ];

    const closeBtn = panel.querySelector(".codeCloseAll") || panel.lastElementChild;

    custom.forEach((tag)=>{
      if (!panel.querySelector('[data-tag="' + tag.t + '"]')){
        const b = document.createElement("button");
        b.type = "button";
        b.className = "codeButtons custom-bb";
        b.dataset.tag = tag.t;
        b.title = tag.n;
        b.setAttribute("aria-label", tag.n);
        b.innerHTML = iconMap[tag.t] || tag.t;

        b.onmousedown = (e)=>{
          e.preventDefault();
          fire(panel, targetId, tag.t);
        };

        if (closeBtn && closeBtn.parentNode === panel) panel.insertBefore(b, closeBtn);
        else panel.appendChild(b);
      }
    });

    const ta = getTargetTextarea(panel, targetId);
    if (ta){
      bindTextarea(ta, panel);

      if (!ta.dataset.ucozEditorHotkeys){
        ta.dataset.ucozEditorHotkeys = "1";
        ta.addEventListener("keydown", (e)=>handleHotkeys(e, panel, targetId), true);
      }

      updateActiveButtons(panel, ta);
    }

    delete panel.dataset.working;
  }

  let rafScheduled = false;
  const observer = new MutationObserver(()=>{
    if (rafScheduled) return;
    rafScheduled = true;
    requestAnimationFrame(()=>{
      rafScheduled = false;
      document.querySelectorAll(".ucoz-editor-panel").forEach(process);
    });
  });

  observer.observe(document.body, {
    childList:true,
    subtree:true,
    attributes:true,
    attributeFilter:["style","class"]
  });

  document.querySelectorAll(".ucoz-editor-panel").forEach(process);
})();
</script>

Как работает редактор после установки

После установки поведение редактора становится стабильным и предсказуемым:

  • Выделенный текст корректно заворачивается в BB+HTML теги.
  • Если текст не выделен, тег вставляется, а курсор ставится внутрь.
  • Списки, цитаты и код вставляются с готовыми шаблонами.
  • Вставка текста из буфера очищается автоматически.
  • Активные кнопки подсвечиваются в зависимости от позиции курсора.
  • Очистка форматирования работает только по выделенному фрагменту.

Заключение

Это решение не пытается заменить BB+HTML редактор uCoz или превратить его в визуальный комбайн. Оно аккуратно доводит стандартный редактор до удобного состояния, в котором можно спокойно писать и оформлять материалы без постоянной борьбы с форматированием.

  • Минимум вмешательства.
  • Максимум практической пользы.
  • И ровно тот уровень комфорта, которого не хватает стандартному редактору.

Оцените полезность материала!

Лицензия: CC BY-SA 4.0

Автор: Юрий Герук

Похожие материалы:

Комментарии