+ */
+ private const COLOR_PALETTE = [
+ 'Black' => '000000',
+ 'Blue' => '38AAFF',
+ 'Brown' => '987857',
+ 'Green' => '79D271',
+ 'Grey' => '888888',
+ 'Orange' => 'FFA700',
+ 'Paper' => 'E0E0E0',
+ 'Purple' => '363E98',
+ 'Red' => 'FF211E',
+ 'White' => 'FEFEFE',
+ 'Yellow' => 'FFD628',
+ ];
+
+ /**
+ * Guards stylesheet injection so N editors on a single page only add the ` `/theme
+ * stylesheet reference once.
+ *
+ * @var bool
+ */
+ private static $styleInjected = false;
+
+ /**
+ * Render the complete DHTML editor toolbar: the xoopscode button row, the typography row,
+ * and the check-length button, wrapped in a single `role="toolbar"` container.
+ *
+ * @param XoopsFormDhtmlTextArea $element form element the toolbar acts on
+ *
+ * @return string rendered toolbar HTML
+ */
+ public function render(XoopsFormDhtmlTextArea $element): string
+ {
+ $ret = $this->injectStylesheet();
+ $ret .= '';
+ $ret .= $this->renderCodeButtons($element);
+ $ret .= $this->renderTypography($element);
+ $ret .= $this->renderCheckLength($element);
+ $ret .= '
';
+
+ return $ret;
+ }
+
+ /**
+ * Render the xoopscode button row (URL, email, image, image manager, smilies, TextSanitizer
+ * extension buttons, code, quote), and fire the `codeicon` preload event.
+ *
+ * @param XoopsFormDhtmlTextArea $element form element (its ->js may be appended to by
+ * TextSanitizer extensions)
+ *
+ * @return string rendered button group
+ */
+ public function renderCodeButtons(XoopsFormDhtmlTextArea $element): string
+ {
+ $textareaId = $element->getName();
+ $btn = self::BTN_SM;
+
+ $code = ' ';
+ $code .= '';
+ $code .= $this->button($btn, $this->jsCall('xoopsCodeUrl', [$textareaId, _ENTERURL, _ENTERWEBTITLE]), _XOOPS_FORM_ALT_URL, 'fa-solid fa-link');
+ $code .= $this->button($btn, $this->jsCall('xoopsCodeEmail', [$textareaId, _ENTEREMAIL, _ENTERWEBTITLE]), _XOOPS_FORM_ALT_EMAIL, 'fa-solid fa-envelope');
+ $code .= $this->button($btn, $this->jsCall('xoopsCodeImg', [$textareaId, _ENTERIMGURL, _ENTERIMGPOS, _IMGPOSRORL, _ERRORIMGPOS, _XOOPS_FORM_ALT_ENTERWIDTH]), _XOOPS_FORM_ALT_IMG, 'fa-solid fa-file-image');
+ $code .= $this->button($btn, $this->jsCall('openWithSelfMain', [XOOPS_URL . '/imagemanager.php?target=' . $textareaId, 'imgmanager', 400, 430]), _XOOPS_FORM_ALT_IMAGE, 'fa-solid fa-file-image', ' Manager ');
+ $code .= $this->button($btn, $this->jsCall('openWithSelfMain', [XOOPS_URL . '/misc.php?action=showpopups&type=smilies&target=' . $textareaId, 'smilies', 300, 475]), _XOOPS_FORM_ALT_SMILEY, 'fa-solid fa-face-smile');
+
+ $myts = \MyTextSanitizer::getInstance();
+ $extensions = array_filter($myts->config['extensions']);
+ foreach (array_keys($extensions) as $key) {
+ $extension = $myts->loadExtension($key);
+ if (!$extension) {
+ continue;
+ }
+ $result = $extension->encode($textareaId);
+ $encode = $result[0] ?? '';
+ $js = $result[1] ?? '';
+ if (empty($encode)) {
+ continue;
+ }
+ // Contract: TextSanitizer extensions hardcode 'btn btn-default btn-sm' in their own
+ // encode() output — rewrite it onto our neutral classes here, once, rather than each
+ // renderer having its own (previously inconsistent) rewrite rule.
+ $code .= $this->rewriteExtensionButtonClasses($encode);
+ if (!empty($js)) {
+ $element->js .= $js;
+ }
+ }
+
+ $code .= $this->button($btn, $this->jsCall('xoopsCodeCode', [$textareaId, _ENTERCODE]), _XOOPS_FORM_ALT_CODE, 'fa-solid fa-code');
+ $code .= $this->button($btn, $this->jsCall('xoopsCodeQuote', [$textareaId, _ENTERQUOTE]), _XOOPS_FORM_ALT_QUOTE, 'fa-solid fa-quote-right');
+ $code .= '
';
+
+ // Contract: fired by reference so third-party modules can append buttons, at the same
+ // logical point as before — after the core buttons and the extensions, immediately before
+ // the code row is returned.
+ $xoopsPreload = \XoopsPreload::getInstance();
+ $xoopsPreload->triggerEvent('core.class.xoopsform.formdhtmltextarea.codeicon', [&$code]);
+
+ return $code;
+ }
+
+ /**
+ * Render the typography row: size / font / colour dropdowns, then bold / italic / underline /
+ * strikethrough and left / center / right alignment button groups.
+ *
+ * @param XoopsFormDhtmlTextArea $element form element
+ *
+ * @return string rendered typography groups
+ */
+ public function renderTypography(XoopsFormDhtmlTextArea $element): string
+ {
+ $textareaId = $element->getName();
+ $hiddenText = (string) $element->_hiddenText;
+
+ $sizes = $GLOBALS['formtextdhtml_sizes'] ?? [];
+ $fonts = !empty($GLOBALS['formtextdhtml_fonts']) ? $GLOBALS['formtextdhtml_fonts'] : self::DEFAULT_FONTS;
+
+ $ret = '';
+ $ret .= $this->dropdown($textareaId, $hiddenText, 'size', _SIZE, 'fa-solid fa-text-height', $sizes);
+ $ret .= $this->dropdown($textareaId, $hiddenText, 'font', _FONT, 'fa-solid fa-font', array_combine($fonts, $fonts));
+ $ret .= $this->dropdown($textareaId, $hiddenText, 'color', _COLOR, 'fa-solid fa-palette', array_flip(self::COLOR_PALETTE), true);
+ $ret .= '
';
+
+ $btn = self::BTN_SM;
+ $ret .= '';
+ $ret .= $this->button($btn, $this->jsCall('xoopsMakeBold', [$hiddenText, $textareaId]), _XOOPS_FORM_ALT_BOLD, 'fa-solid fa-bold');
+ $ret .= $this->button($btn, $this->jsCall('xoopsMakeItalic', [$hiddenText, $textareaId]), _XOOPS_FORM_ALT_ITALIC, 'fa-solid fa-italic');
+ $ret .= $this->button($btn, $this->jsCall('xoopsMakeUnderline', [$hiddenText, $textareaId]), _XOOPS_FORM_ALT_UNDERLINE, 'fa-solid fa-underline');
+ $ret .= $this->button($btn, $this->jsCall('xoopsMakeLineThrough', [$hiddenText, $textareaId]), _XOOPS_FORM_ALT_LINETHROUGH, 'fa-solid fa-strikethrough');
+ $ret .= '
';
+
+ $ret .= '';
+ $ret .= $this->button($btn, $this->jsCall('xoopsMakeLeft', [$hiddenText, $textareaId]), _XOOPS_FORM_ALT_LEFT, 'fa-solid fa-align-left');
+ $ret .= $this->button($btn, $this->jsCall('xoopsMakeCenter', [$hiddenText, $textareaId]), _XOOPS_FORM_ALT_CENTER, 'fa-solid fa-align-center');
+ $ret .= $this->button($btn, $this->jsCall('xoopsMakeRight', [$hiddenText, $textareaId]), _XOOPS_FORM_ALT_RIGHT, 'fa-solid fa-align-right');
+ $ret .= '
';
+
+ return $ret;
+ }
+
+ /**
+ * Render the check-length button, reading `configs['maxlength']` off the element the same way
+ * every renderer did before this refactor.
+ *
+ * @param XoopsFormDhtmlTextArea $element form element
+ *
+ * @return string rendered check-length button group
+ */
+ public function renderCheckLength(XoopsFormDhtmlTextArea $element): string
+ {
+ $maxlength = 0;
+ // 'configs' is a legacy dynamic property some callers set on the element; guard the
+ // access so PHP 8.2+ does not warn when it was never set.
+ if (property_exists($element, 'configs') && is_array($element->configs) && isset($element->configs['maxlength'])) {
+ $maxlength = (int) $element->configs['maxlength'];
+ }
+
+ $onclick = $this->jsCall('XoopsCheckLength', [$element->getName(), (string) $maxlength, _XOOPS_FORM_ALT_LENGTH, _XOOPS_FORM_ALT_LENGTH_MAX]);
+
+ return ''
+ . $this->button(self::BTN_SM, $onclick, _XOOPS_FORM_ALT_CHECKLENGTH, 'fa-solid fa-square-check')
+ . '
';
+ }
+
+ /**
+ * Rewrite the class attribute TextSanitizer extensions hardcode ('btn btn-default btn-sm' or
+ * 'btn btn-default') onto this toolbar's neutral classes. Also catches the per-renderer
+ * remaps that existed before this refactor ('btn-secondary'), so extension output is
+ * consistent no matter which renderer/version produced it.
+ *
+ * @param string $html extension-supplied button HTML
+ *
+ * @return string HTML with neutral classes substituted in
+ */
+ protected function rewriteExtensionButtonClasses(string $html): string
+ {
+ return str_replace(
+ ['btn btn-default btn-sm', 'btn btn-default', 'btn-secondary', 'btn-default'],
+ [self::BTN_SM, self::BTN, self::BTN, self::BTN],
+ $html
+ );
+ }
+
+ /**
+ * Render a single toolbar button.
+ *
+ * @param string $class button CSS class(es)
+ * @param string $onclickJs JavaScript expression for the onclick handler (already a
+ * complete statement, e.g. from {@see self::jsCall()})
+ * @param string $title tooltip text (escaped internally)
+ * @param string $iconClass Font Awesome icon class string
+ * @param string $trailingHtml optional pre-built HTML appended after the icon
+ *
+ * @return string rendered element
+ */
+ protected function button(string $class, string $onclickJs, string $title, string $iconClass, string $trailingHtml = ''): string
+ {
+ $escapedTitle = htmlspecialchars($title, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+
+ return ""
+ . " " . $trailingHtml . ' ';
+ }
+
+ /**
+ * Render a single ``/`` dropdown (size, font, or colour). Using native
+ * `` keeps the toolbar framework-neutral and script-free for the menu interaction
+ * itself — no Bootstrap/DaisyUI JS or data-attributes are required.
+ *
+ * @param string $textareaId id of the textarea the dropdown acts on
+ * @param string $hiddenText hidden-text element id passed to xoopsSetElementAttribute
+ * @param string $attr attribute name passed to xoopsSetElementAttribute ('size'|'font'|'color')
+ * @param string $label dropdown button tooltip / aria-label
+ * @param string $iconClass Font Awesome icon class string for the toggle button
+ * @param array $options attrValue => display label
+ * @param bool $isColor when true, render a colour swatch (attrValue is a hex string)
+ *
+ * @return string rendered dropdown
+ */
+ protected function dropdown(string $textareaId, string $hiddenText, string $attr, string $label, string $iconClass, array $options, bool $isColor = false): string
+ {
+ $escapedLabel = htmlspecialchars($label, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+
+ // name= makes sibling mutually exclusive natively (HTML "exclusive accordion"),
+ // so opening Font closes Size with no script at all. Scoped per textarea id so two
+ // editors on one page do not close each other's menus. assets/toolbar.js provides the
+ // same behaviour for browsers that predate the attribute, plus click-outside/Escape.
+ $groupName = 'xo-edtb-' . preg_replace('/[^A-Za-z0-9_-]/', '', $textareaId);
+
+ $ret = ''
+ . ''
+ . ' '
+ . ' ';
+
+ return $ret;
+ }
+
+ /**
+ * Build a JavaScript function-call expression as a complete statement (trailing `;`), string
+ * arguments HTML-attribute-escaped so the result is safe inside a single-quoted `onclick='...'`
+ * attribute. Integer/float arguments are emitted unquoted.
+ *
+ * @param string $fn JavaScript function name (hard-coded literal — never
+ * build this from user input)
+ * @param array $args arguments, in order
+ *
+ * @return string JavaScript statement, e.g. `xoopsCodeUrl("id", "Enter URL");`
+ */
+ protected function jsCall(string $fn, array $args): string
+ {
+ $parts = [];
+ foreach ($args as $arg) {
+ if (is_int($arg) || is_float($arg)) {
+ $parts[] = (string) $arg;
+ } else {
+ $parts[] = '"' . htmlspecialchars((string) $arg, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '"';
+ }
+ }
+
+ return $fn . '(' . implode(', ', $parts) . ');';
+ }
+
+ /**
+ * Inject the toolbar stylesheet once per request: via `$xoTheme->addStylesheet()` when a theme
+ * is available, otherwise as an inline ` ` tag — mirroring how the renderers already fall
+ * back for `image.js`. Guarded by a static flag so N editors on one page inject it once.
+ *
+ * @return string a ` ` tag when no theme is available, otherwise an empty string
+ */
+ protected function injectStylesheet(): string
+ {
+ if (self::$styleInjected) {
+ return '';
+ }
+ self::$styleInjected = true;
+
+ $href = 'class/xoopseditor/dhtmltextarea/assets/toolbar.css';
+ $src = 'class/xoopseditor/dhtmltextarea/assets/toolbar.js';
+
+ if (!empty($GLOBALS['xoTheme']) && is_object($GLOBALS['xoTheme'])) {
+ $GLOBALS['xoTheme']->addStylesheet($href);
+ $GLOBALS['xoTheme']->addScript($src);
+
+ return '';
+ }
+
+ return ' ' . "\n"
+ . '' . "\n";
+ }
+}
diff --git a/htdocs/class/xoopseditor/dhtmltextarea/assets/toolbar.css b/htdocs/class/xoopseditor/dhtmltextarea/assets/toolbar.css
new file mode 100644
index 000000000..a63f6298e
--- /dev/null
+++ b/htdocs/class/xoopseditor/dhtmltextarea/assets/toolbar.css
@@ -0,0 +1,193 @@
+/**
+ * Self-contained styling for the shared XOOPS DHTML editor toolbar (XoopsDhtmlToolbar).
+ *
+ * Framework-neutral: every rule targets the `xo-edtb-*` prefix only, so this renders the same
+ * whether or not Bootstrap/Tailwind/DaisyUI is loaded — the admin themes load no CSS framework.
+ * Every colour, radius, spacing and size is a CSS custom property declared on the toolbar root,
+ * so a theme can restyle the toolbar by overriding variables instead of rewriting rules.
+ *
+ * You may not change or alter any portion of this comment or credits of supporting developers
+ * from this source code or any supporting source code which is considered copyrighted (c)
+ * material of the original comment or credit authors. This program is distributed in the hope
+ * that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ *
+ * @copyright 2000-2026 XOOPS Project (https://xoops.org)
+ * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html)
+ */
+
+.xo-edtb-toolbar {
+ --xo-edtb-bg: #f8f9fa;
+ --xo-edtb-border: #ced4da;
+ --xo-edtb-radius: 4px;
+ --xo-edtb-gap: 4px;
+ --xo-edtb-padding: 4px;
+ --xo-edtb-font-size: 0.8125rem;
+
+ --xo-edtb-btn-size: 1.75rem;
+ --xo-edtb-btn-bg: #ffffff;
+ --xo-edtb-btn-fg: #212529;
+ --xo-edtb-btn-border: #ced4da;
+ --xo-edtb-btn-hover-bg: #e9ecef;
+ --xo-edtb-btn-active-bg: #dee2e6;
+
+ --xo-edtb-menu-bg: #ffffff;
+ --xo-edtb-menu-fg: #212529;
+ --xo-edtb-menu-border: #ced4da;
+ --xo-edtb-menu-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--xo-edtb-gap);
+ padding: var(--xo-edtb-padding);
+ background: var(--xo-edtb-bg);
+ border: 1px solid var(--xo-edtb-border);
+ border-radius: var(--xo-edtb-radius);
+ font-size: var(--xo-edtb-font-size);
+ line-height: 1;
+}
+
+.xo-edtb-toolbar,
+.xo-edtb-toolbar * {
+ box-sizing: border-box;
+}
+
+.xo-edtb-group {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--xo-edtb-gap);
+}
+
+.xo-edtb-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.25rem;
+ min-width: var(--xo-edtb-btn-size);
+ height: var(--xo-edtb-btn-size);
+ padding: 0 0.4rem;
+ background: var(--xo-edtb-btn-bg);
+ color: var(--xo-edtb-btn-fg);
+ border: 1px solid var(--xo-edtb-btn-border);
+ border-radius: var(--xo-edtb-radius);
+ font-size: inherit;
+ line-height: 1;
+ cursor: pointer;
+ user-select: none;
+}
+
+.xo-edtb-btn:hover {
+ background: var(--xo-edtb-btn-hover-bg);
+}
+
+.xo-edtb-btn:active {
+ background: var(--xo-edtb-btn-active-bg);
+}
+
+.xo-edtb-btn-sm {
+ padding: 0 0.3rem;
+}
+
+/* Dropdowns are native / — no JS required for the open/close interaction. */
+.xo-edtb-dropdown {
+ position: relative;
+}
+
+.xo-edtb-dropdown > summary.xo-edtb-btn {
+ list-style: none;
+}
+
+.xo-edtb-dropdown > summary::-webkit-details-marker {
+ display: none;
+}
+
+.xo-edtb-dropdown > summary::marker {
+ content: '';
+}
+
+.xo-edtb-menu {
+ position: absolute;
+ z-index: 1000;
+ top: 100%;
+ left: 0;
+ margin: 2px 0 0;
+ padding: 4px 0;
+ min-width: 9rem;
+ max-height: 16rem;
+ overflow-y: auto;
+ list-style: none;
+ background: var(--xo-edtb-menu-bg);
+ color: var(--xo-edtb-menu-fg);
+ border: 1px solid var(--xo-edtb-menu-border);
+ border-radius: var(--xo-edtb-radius);
+ box-shadow: var(--xo-edtb-menu-shadow);
+}
+
+/*
+ * Restore the resize grip on the editor's textarea.
+ *
+ * The "default" and "transition" ADMIN themes ship a universal reset —
+ * `* { … resize:none; }` (modules/system/themes/{default,transition}/css/reset.css:13) — which
+ * strips the grip from every element on the page, so the control panel's editor could not be
+ * resized while the front end's could. The textarea is rendered as a sibling immediately after
+ * the toolbar, so this sibling selector reaches it without needing a class on the element (which
+ * would mean touching all five renderers). Specificity (0,1,1) comfortably beats `*` (0,0,0).
+ */
+.xo-edtb-toolbar ~ textarea {
+ resize: vertical;
+}
+
+/*
+ * Reset the list markers on the ITEMS, not just the list. The admin "default" theme ships a
+ * bare `li { list-style: square inside; }` (modules/system/themes/default/css/style.css:59-62),
+ * which applies directly to the and therefore beats a `list-style: none` set only on the
+ * parent . That is why square bullets appeared in the control panel but not on the front
+ * end. `.xo-edtb-menu > li` (0,1,1) outranks a bare `li` (0,0,1) without needing !important.
+ */
+.xo-edtb-menu > li {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.xo-edtb-menu-item {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.25rem 0.6rem;
+ color: inherit;
+ text-decoration: none;
+ white-space: nowrap;
+}
+
+.xo-edtb-menu-item:hover {
+ background: var(--xo-edtb-btn-hover-bg);
+}
+
+.xo-edtb-swatch {
+ display: inline-block;
+ width: 0.9rem;
+ height: 0.9rem;
+ border: 1px solid var(--xo-edtb-menu-border);
+ border-radius: 2px;
+}
+
+@media (prefers-color-scheme: dark) {
+ .xo-edtb-toolbar {
+ --xo-edtb-bg: #2b2f33;
+ --xo-edtb-border: #495057;
+
+ --xo-edtb-btn-bg: #343a40;
+ --xo-edtb-btn-fg: #e9ecef;
+ --xo-edtb-btn-border: #495057;
+ --xo-edtb-btn-hover-bg: #495057;
+ --xo-edtb-btn-active-bg: #5a6268;
+
+ --xo-edtb-menu-bg: #343a40;
+ --xo-edtb-menu-fg: #e9ecef;
+ --xo-edtb-menu-border: #495057;
+ --xo-edtb-menu-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
+ }
+}
diff --git a/htdocs/class/xoopseditor/dhtmltextarea/assets/toolbar.js b/htdocs/class/xoopseditor/dhtmltextarea/assets/toolbar.js
new file mode 100644
index 000000000..fd8142346
--- /dev/null
+++ b/htdocs/class/xoopseditor/dhtmltextarea/assets/toolbar.js
@@ -0,0 +1,71 @@
+/*
+ * Shared DHTML editor toolbar behaviour.
+ *
+ * The dropdowns are native /, and each toolbar's set shares a name= so modern
+ * browsers make them mutually exclusive on their own. This file supplies the rest:
+ *
+ * 1. the same exclusivity for browsers that predate the name= "exclusive accordion" attribute
+ * (name= on is only supported in newer engines);
+ * 2. closing the open menu when the user clicks anywhere outside it;
+ * 3. closing it on Escape, and returning focus to the toggle.
+ *
+ * Everything is delegated from document, so toolbars added to the page later (AJAX forms, a
+ * second editor) are handled without re-initialising anything.
+ */
+(function () {
+ 'use strict';
+
+ if (window.xoopsEditorToolbarInit) {
+ return;
+ }
+ window.xoopsEditorToolbarInit = true;
+
+ var DROPDOWN = 'details.xo-edtb-dropdown';
+
+ /**
+ * Close every open dropdown except the one passed in.
+ *
+ * @param {Element|null} keepOpen dropdown to leave alone
+ * @param {Element|null} scope toolbar to limit the sweep to, or null for the document
+ */
+ function closeOthers(keepOpen, scope) {
+ var root = scope || document;
+ var open = root.querySelectorAll(DROPDOWN + '[open]');
+ for (var i = 0; i < open.length; i++) {
+ if (open[i] !== keepOpen) {
+ open[i].removeAttribute('open');
+ }
+ }
+ }
+
+ // 1 + 2: opening one closes the others. 'toggle' fires on open AND close, so only act on open.
+ document.addEventListener('toggle', function (event) {
+ var details = event.target;
+ if (!details || !details.matches || !details.matches(DROPDOWN) || !details.hasAttribute('open')) {
+ return;
+ }
+ // Limit to the owning toolbar so a second editor on the page is unaffected.
+ closeOthers(details, details.closest('.xo-edtb-toolbar'));
+ }, true); // capture: 'toggle' does not bubble
+
+ // 2: click anywhere outside an open dropdown closes it.
+ document.addEventListener('click', function (event) {
+ var inside = event.target.closest ? event.target.closest(DROPDOWN) : null;
+ closeOthers(inside, null);
+ });
+
+ // 3: Escape closes the open dropdown and puts focus back on its toggle.
+ document.addEventListener('keydown', function (event) {
+ if (event.key !== 'Escape' && event.keyCode !== 27) {
+ return;
+ }
+ var open = document.querySelectorAll(DROPDOWN + '[open]');
+ for (var i = 0; i < open.length; i++) {
+ var summary = open[i].querySelector('summary');
+ open[i].removeAttribute('open');
+ if (summary && typeof summary.focus === 'function') {
+ summary.focus();
+ }
+ }
+ });
+})();
diff --git a/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap3.php b/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap3.php
index 8eb88ed7e..44862bfac 100644
--- a/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap3.php
+++ b/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap3.php
@@ -9,6 +9,7 @@
*/
require_once __DIR__ . '/XoopsFormTabRendererInterface.php';
+require_once __DIR__ . '/../../xoopseditor/dhtmltextarea/XoopsDhtmlToolbar.php';
/**
* Bootstrap3 style form renderer
@@ -229,13 +230,9 @@ public function renderFormDhtmlTextArea(XoopsFormDhtmlTextArea $element)
{
xoops_loadLanguage('formdhtmltextarea');
$ret = '';
- // actions
- $ret .= $this->renderFormDhtmlTAXoopsCode($element) . " \n";
- // fonts
- $ret .= $this->renderFormDhtmlTATypography($element);
- // length checker
-
- $ret .= " \n";
+ // toolbar: xoopscode buttons, typography, check-length — shared across all renderers
+ $toolbar = new \XoopsDhtmlToolbar();
+ $ret .= $toolbar->render($element) . " \n";
// the textarea box
$ret .= "