Commit 7e733f1a authored by Jan Köster's avatar Jan Köster
Browse files

emoji

parent 0838aae3
Loading
Loading
Loading
Loading
+9 −0
Changes for editor/html/index.html: 9 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -126,6 +126,15 @@
        </ul>
    </div>

    <!-- Rich-text icon context menu: opened by right-click inside the Textbox
         rich-text editor (properties.js), inserts an Icon Library pick inline
         at the cursor position. -->
    <div id="rte-icon-menu" class="floating-panel context-menu" style="display:none">
        <ul>
            <li data-action="insert-icon" data-i18n="I18N_INSERT_ICON">Icon aus Bibliothek einf&uuml;gen&hellip;</li>
        </ul>
    </div>

    <!-- Dialogs -->
    <!-- Merged Projects + Templates modal: the sidebar picks a project (which auto-
         connects and filters the template list); the content area shows that project's
+123 −5
Changes for editor/html/js/properties.js: 123 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -16,6 +16,69 @@ var PropertiesPanel = (function() {
        currentLevel = localStorage.getItem('blogi-properties-level') || 'simple';
    } catch(e) {}

    // --- Rich-text "insert icon" context menu (right-click inside a Textbox's
    // rich-text content editor). Bound once (lazily, on first RTE render) and
    // reused across every RTE instance — the actual target editor/hidden-input
    // pair is captured fresh on each right-click via _rteIconMenuTarget.
    var _rteIconMenuBound = false;
    var _rteIconMenuTarget = null;

    function _closeRteIconMenu() {
        var menu = document.getElementById('rte-icon-menu');
        if (menu) menu.style.display = 'none';
        document.removeEventListener('mousedown', _onRteIconMenuOutsideClick, true);
    }

    function _onRteIconMenuOutsideClick(ev) {
        var menu = document.getElementById('rte-icon-menu');
        if (menu && !menu.contains(ev.target)) _closeRteIconMenu();
    }

    function _openRteIconMenu(x, y, ed, hi) {
        var menu = document.getElementById('rte-icon-menu');
        if (!menu) return;
        _rteIconMenuTarget = { ed: ed, hi: hi };
        menu.style.left = x + 'px';
        menu.style.top = y + 'px';
        menu.style.display = 'block';
        setTimeout(function() {
            document.addEventListener('mousedown', _onRteIconMenuOutsideClick, true);
        }, 0);
    }

    function _bindRteIconMenu() {
        if (_rteIconMenuBound) return;
        _rteIconMenuBound = true;
        var menu = document.getElementById('rte-icon-menu');
        if (!menu) return;
        menu.addEventListener('click', function(e) {
            var item = e.target.closest('li[data-action="insert-icon"]');
            if (!item) return;
            _closeRteIconMenu();
            var target = _rteIconMenuTarget;
            if (!target || typeof IconLibrary === 'undefined') return;
            IconLibrary.open(function(icon) {
                // Re-derive a fresh 1em-sized <svg> via real DOM parsing (much
                // simpler here than the C++ side's hand-rolled scanner) so a
                // library icon authored with its own fixed width/height still
                // scales to the surrounding text instead of rendering at its
                // original pixel size.
                var wrapper = document.createElement('span');
                wrapper.innerHTML = icon.svg || '';
                var svgEl = wrapper.querySelector('svg');
                if (svgEl) {
                    svgEl.setAttribute('width', '1em');
                    svgEl.setAttribute('height', '1em');
                    svgEl.style.verticalAlign = 'middle';
                }
                target.ed.focus();
                document.execCommand('insertHTML', false, wrapper.innerHTML);
                target.hi.value = target.ed.innerHTML;
                target.hi.dispatchEvent(new Event('change'));
            });
        });
    }

    // The properties panel is docked in #sidebar-right; rect (formerly used to
    // position a floating panel near a canvas click) is no longer needed but
    // still accepted so callers (Canvas selection) don't need special-casing.
@@ -598,6 +661,12 @@ var PropertiesPanel = (function() {
                        hiddenInput.value = sourceArea.value;
                    });

                    _bindRteIconMenu();
                    editorDiv.addEventListener('contextmenu', function(e) {
                        e.preventDefault();
                        _openRteIconMenu(e.clientX, e.clientY, editorDiv, hiddenInput);
                    });

                    for (var ti = 0; ti < toolbarDefs.length; ti++) {
                        var def = toolbarDefs[ti];
                        if (def.sep) {
@@ -859,11 +928,17 @@ var PropertiesPanel = (function() {
                            IconLibrary.open(function(icon) {
                                inp.value = icon.id;
                                preview.innerHTML = icon.svg || '';
                                var svgInput = form.querySelector('textarea[name="svg_markup"]');
                                // Denormalized copy: each icon_browse field declares which
                                // sibling field (a visible textarea, e.g. Svg's "svg_markup",
                                // or a plain hidden field, e.g. Textbox's "custom_icon_svg")
                                // stores the resolved SVG source that Render() actually uses.
                                if (field.svgTargetKey) {
                                    var svgInput = form.querySelector('[name="' + field.svgTargetKey + '"]');
                                    if (svgInput) {
                                        svgInput.value = icon.svg || '';
                                        svgInput.dispatchEvent(new Event('change'));
                                    }
                                }
                                inp.dispatchEvent(new Event('change'));
                            });
                        });
@@ -874,6 +949,13 @@ var PropertiesPanel = (function() {
                    group.appendChild(iconWrapper);
                    panel.appendChild(group);
                    continue;
                } else if (field.type === 'hidden') {
                    input = document.createElement('input');
                    input.type = 'hidden';
                    input.name = field.key;
                    input.value = value;
                    panel.appendChild(input);
                    continue;
                } else if (field.type === 'faq_items') {
                    var faqWrap = document.createElement('div');
                    faqWrap.className = 'faq-items-editor';
@@ -1135,15 +1217,46 @@ var PropertiesPanel = (function() {
                            });
                            cardHeader.appendChild(removeBtn);

                            var emojiRow = document.createElement('div');
                            emojiRow.style.cssText = 'display:flex;align-items:center;gap:6px;';

                            var emojiIconPreview = document.createElement('span');
                            emojiIconPreview.style.cssText = 'display:inline-flex;width:20px;height:20px;flex:none;';
                            if (item.icon_source === 'custom' && item.custom_icon_svg) {
                                emojiIconPreview.innerHTML = item.custom_icon_svg;
                            }

                            var emojiInput = document.createElement('input');
                            emojiInput.type = 'text';
                            emojiInput.placeholder = 'Emoji, z.B. 🔗';
                            emojiInput.className = 'faq-item-question';
                            emojiInput.style.flex = '1';
                            emojiInput.value = item.emoji || '';
                            emojiInput.addEventListener('input', function() {
                                trlList[idx].emoji = emojiInput.value;
                                trlList[idx].icon_source = 'emoji';
                                emojiIconPreview.innerHTML = '';
                                trlSyncHidden();
                            });

                            var emojiIconBtn = document.createElement('button');
                            emojiIconBtn.type = 'button';
                            emojiIconBtn.textContent = '🖼️';
                            emojiIconBtn.title = I18n.t('I18N_CHOOSE_ICON', 'Icon wählen');
                            emojiIconBtn.addEventListener('click', function() {
                                if (typeof IconLibrary === 'undefined') return;
                                IconLibrary.open(function(icon) {
                                    trlList[idx].icon_source = 'custom';
                                    trlList[idx].custom_icon_id = icon.id;
                                    trlList[idx].custom_icon_svg = icon.svg || '';
                                    emojiIconPreview.innerHTML = icon.svg || '';
                                    trlSyncHidden();
                                });
                            });

                            emojiRow.appendChild(emojiIconPreview);
                            emojiRow.appendChild(emojiInput);
                            emojiRow.appendChild(emojiIconBtn);

                            var labelInput = document.createElement('input');
                            labelInput.type = 'text';
@@ -1178,7 +1291,7 @@ var PropertiesPanel = (function() {
                            newTabLabel.appendChild(document.createTextNode(' In neuem Tab öffnen'));

                            card.appendChild(cardHeader);
                            card.appendChild(emojiInput);
                            card.appendChild(emojiRow);
                            card.appendChild(labelInput);
                            card.appendChild(urlInput);
                            card.appendChild(newTabLabel);
@@ -1194,7 +1307,7 @@ var PropertiesPanel = (function() {
                    trlAddBtn.className = 'faq-item-add';
                    trlAddBtn.textContent = '+ Link hinzufügen';
                    trlAddBtn.addEventListener('click', function() {
                        trlList.push({ emoji: '', label: '', url: '', new_tab: true });
                        trlList.push({ icon_source: 'emoji', emoji: '', custom_icon_id: '', custom_icon_svg: '', label: '', url: '', new_tab: true });
                        trlSyncHidden();
                        trlRenderList();
                    });
@@ -1269,10 +1382,15 @@ var PropertiesPanel = (function() {
            var watched = panel.querySelector('[name="' + field.visibleWhen.key + '"]');
            if (!watched) continue;

            var expected = String(field.visibleWhen.value);
            // value may be a single string (exact match) or an array of
            // strings (OR-match — e.g. Svg's Color field is relevant for both
            // source_type "inline" and "icon", just not "media").
            var expectedRaw = field.visibleWhen.value;
            var expected = Array.isArray(expectedRaw) ? expectedRaw.map(String) : String(expectedRaw);
            var evaluate = function(watchedEl, groupEl, expectedVal) {
                var actual = watchedEl.type === 'checkbox' ? String(watchedEl.checked) : watchedEl.value;
                groupEl.setAttribute('data-cond-hidden', (actual === expectedVal) ? '0' : '1');
                var matches = Array.isArray(expectedVal) ? expectedVal.indexOf(actual) >= 0 : (actual === expectedVal);
                groupEl.setAttribute('data-cond-hidden', matches ? '0' : '1');
                _updateGroupDisplay(groupEl);
            };
            (function(watchedEl, groupEl, expectedVal) {
+102 −0
Changes for editor/widgets/common/svg_markup.h: 102 added lines, 0 removed lines.
Original line number Diff line number Diff line
/*******************************************************************************
 * Shared helper for widgets that embed raw SVG markup (Svg, and any widget
 * offering a "custom icon from the Icon Library" mode: Textbox, TrustLinks).
 * Header-only so each widget's independently-built .so simply #includes it —
 * no shared library / CMake target needed.
 *******************************************************************************/

#pragma once

#include <string>
#include <cstring>
#include <cctype>

namespace blogi::webedit::svgmarkup {

    // Splits raw pasted/uploaded SVG markup into its viewBox (if any) and inner
    // content, without routing it through htmlpp's HTML tokenizer: that
    // tokenizer lowercases tag/attribute names, which would corrupt
    // case-sensitive SVG attributes such as viewBox or preserveAspectRatio on
    // the outer tag. This scan only inspects the outer <svg ...> tag; nested
    // markup is passed through byte for byte and later carried verbatim
    // (typically re-wrapped in a fresh <svg width="..." height="..."> tag sized
    // for the icon slot it's rendered into).
    inline void parse(const std::string &raw, std::string &viewBoxOut, std::string &innerOut) {
        viewBoxOut.clear();
        innerOut.clear();

        size_t start = raw.find_first_not_of(" \t\r\n");
        if (start == std::string::npos) return;
        size_t end = raw.find_last_not_of(" \t\r\n");
        std::string s = raw.substr(start, end - start + 1);

        auto toLowerAscii = [](unsigned char c) -> unsigned char {
            return (c >= 'A' && c <= 'Z') ? static_cast<unsigned char>(c + 32) : c;
        };
        auto startsWithCi = [&](const std::string &str, const char *prefix) {
            size_t plen = std::strlen(prefix);
            if (str.size() < plen) return false;
            for (size_t i = 0; i < plen; ++i)
                if (toLowerAscii(static_cast<unsigned char>(str[i])) != toLowerAscii(static_cast<unsigned char>(prefix[i])))
                    return false;
            return true;
        };

        if (!startsWithCi(s, "<svg")) {
            // No outer <svg> wrapper — treat the whole thing as an inner
            // fragment (advanced users pasting bare paths).
            innerOut = s;
            return;
        }

        size_t tagEnd = s.find('>', 4);
        if (tagEnd == std::string::npos) return;
        bool selfClosed = tagEnd > 0 && s[tagEnd - 1] == '/';
        std::string openTag = s.substr(0, tagEnd + 1);

        // Scan attributes on the outer tag looking for viewBox=, matching the
        // key case-insensitively but keeping the value exactly as written.
        size_t i = 4;
        while (i < openTag.size()) {
            while (i < openTag.size() && std::isspace(static_cast<unsigned char>(openTag[i]))) ++i;
            if (i >= openTag.size() || openTag[i] == '>' || openTag[i] == '/') break;

            size_t kstart = i;
            while (i < openTag.size() && openTag[i] != '=' &&
                   !std::isspace(static_cast<unsigned char>(openTag[i])) &&
                   openTag[i] != '>' && openTag[i] != '/') ++i;
            std::string key = openTag.substr(kstart, i - kstart);

            while (i < openTag.size() && std::isspace(static_cast<unsigned char>(openTag[i]))) ++i;

            std::string val;
            if (i < openTag.size() && openTag[i] == '=') {
                ++i;
                while (i < openTag.size() && std::isspace(static_cast<unsigned char>(openTag[i]))) ++i;
                if (i < openTag.size() && (openTag[i] == '"' || openTag[i] == '\'')) {
                    char quote = openTag[i++];
                    size_t vstart = i;
                    while (i < openTag.size() && openTag[i] != quote) ++i;
                    val = openTag.substr(vstart, i - vstart);
                    if (i < openTag.size()) ++i;
                }
            }

            std::string keyLower = key;
            for (char &ch : keyLower) ch = static_cast<char>(toLowerAscii(static_cast<unsigned char>(ch)));
            if (keyLower == "viewbox") viewBoxOut = val;
        }

        if (selfClosed) return;

        std::string lowerS = s;
        for (char &ch : lowerS) ch = static_cast<char>(toLowerAscii(static_cast<unsigned char>(ch)));
        size_t closePos = lowerS.rfind("</svg");
        if (closePos == std::string::npos || closePos <= tagEnd) {
            innerOut = s.substr(tagEnd + 1);
            return;
        }
        innerOut = s.substr(tagEnd + 1, closePos - (tagEnd + 1));
    }

}
+19 −90
Changes for editor/widgets/svg/svg.cpp: 19 added lines, 90 removed lines.
Original line number Diff line number Diff line
@@ -35,6 +35,7 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

#include "plugin.h"
#include "translations.h"
#include "../common/svg_markup.h"

extern "C" {
    namespace blogi::webedit {
@@ -45,7 +46,7 @@ extern "C" {

            // Inline mode: raw pasted SVG markup (full <svg>...</svg> or just
            // the inner fragment). viewBox and the inner content are derived
            // from this on every render (see parseSvgMarkup) rather than
            // from this on every render (see svgmarkup::parse) rather than
            // persisted separately, so there is a single source of truth.
            // Icon mode reuses this exact field: picking an icon from the
            // library copies its SVG source in here (denormalized, like
@@ -105,93 +106,6 @@ extern "C" {
                std::cerr << "SvgPlugin Error (" << getInstanceId().c_str() << "): " << message << std::endl;
            }

            // --- Markup helpers ---
            // Splits raw pasted SVG markup into its viewBox (if any) and inner
            // content, without routing it through htmlpp's HTML tokenizer:
            // that tokenizer lowercases tag/attribute names, which would
            // corrupt case-sensitive SVG attributes such as viewBox or
            // preserveAspectRatio on the outer tag. This scan only inspects
            // the outer <svg ...> tag; nested markup is passed through byte
            // for byte and later carried verbatim via libhtmlpp::SvgElement.
            static void parseSvgMarkup(const std::string &raw, std::string &viewBoxOut, std::string &innerOut) {
                viewBoxOut.clear();
                innerOut.clear();

                size_t start = raw.find_first_not_of(" \t\r\n");
                if (start == std::string::npos) return;
                size_t end = raw.find_last_not_of(" \t\r\n");
                std::string s = raw.substr(start, end - start + 1);

                auto toLowerAscii = [](unsigned char c) -> unsigned char {
                    return (c >= 'A' && c <= 'Z') ? static_cast<unsigned char>(c + 32) : c;
                };
                auto startsWithCi = [&](const std::string &str, const char *prefix) {
                    size_t plen = std::strlen(prefix);
                    if (str.size() < plen) return false;
                    for (size_t i = 0; i < plen; ++i)
                        if (toLowerAscii(static_cast<unsigned char>(str[i])) != toLowerAscii(static_cast<unsigned char>(prefix[i])))
                            return false;
                    return true;
                };

                if (!startsWithCi(s, "<svg")) {
                    // No outer <svg> wrapper — treat the whole thing as an
                    // inner fragment (advanced users pasting bare paths).
                    innerOut = s;
                    return;
                }

                size_t tagEnd = s.find('>', 4);
                if (tagEnd == std::string::npos) return;
                bool selfClosed = tagEnd > 0 && s[tagEnd - 1] == '/';
                std::string openTag = s.substr(0, tagEnd + 1);

                // Scan attributes on the outer tag looking for viewBox=,
                // matching the key case-insensitively but keeping the value
                // exactly as written.
                size_t i = 4;
                while (i < openTag.size()) {
                    while (i < openTag.size() && std::isspace(static_cast<unsigned char>(openTag[i]))) ++i;
                    if (i >= openTag.size() || openTag[i] == '>' || openTag[i] == '/') break;

                    size_t kstart = i;
                    while (i < openTag.size() && openTag[i] != '=' &&
                           !std::isspace(static_cast<unsigned char>(openTag[i])) &&
                           openTag[i] != '>' && openTag[i] != '/') ++i;
                    std::string key = openTag.substr(kstart, i - kstart);

                    while (i < openTag.size() && std::isspace(static_cast<unsigned char>(openTag[i]))) ++i;

                    std::string val;
                    if (i < openTag.size() && openTag[i] == '=') {
                        ++i;
                        while (i < openTag.size() && std::isspace(static_cast<unsigned char>(openTag[i]))) ++i;
                        if (i < openTag.size() && (openTag[i] == '"' || openTag[i] == '\'')) {
                            char quote = openTag[i++];
                            size_t vstart = i;
                            while (i < openTag.size() && openTag[i] != quote) ++i;
                            val = openTag.substr(vstart, i - vstart);
                            if (i < openTag.size()) ++i;
                        }
                    }

                    std::string keyLower = key;
                    for (char &ch : keyLower) ch = static_cast<char>(toLowerAscii(static_cast<unsigned char>(ch)));
                    if (keyLower == "viewbox") viewBoxOut = val;
                }

                if (selfClosed) return;

                std::string lowerS = s;
                for (char &ch : lowerS) ch = static_cast<char>(toLowerAscii(static_cast<unsigned char>(ch)));
                size_t closePos = lowerS.rfind("</svg");
                if (closePos == std::string::npos || closePos <= tagEnd) {
                    innerOut = s.substr(tagEnd + 1);
                    return;
                }
                innerOut = s.substr(tagEnd + 1, closePos - (tagEnd + 1));
            }

            // --- UI Settings (legacy panel; superseded by getSchemaJson()/JsonApi) ---
            void Settings(libhttppp::HttpRequest &curreq, libhtmlpp::HtmlElement &settings) override {
                libhtmlpp::HtmlElement title("h3");
@@ -524,15 +438,30 @@ extern "C" {
                    json_object_object_add(v, "value", json_object_new_string(value));
                    return v;
                };
                auto mkVisibleAny = [](const char *key, std::initializer_list<const char*> values) {
                    json_object *v = json_object_new_object();
                    json_object_object_add(v, "key", json_object_new_string(key));
                    json_object *arr2 = json_object_new_array();
                    for (auto val : values) json_object_array_add(arr2, json_object_new_string(val));
                    json_object_object_add(v, "value", arr2);
                    return v;
                };

                addField("source_type", "Source", "select", nullptr, "all", mkOpts({"inline", "media", "icon"}), nullptr, "simple");
                addField("svg_markup", "SVG Markup", "textarea", "<svg viewBox=\"0 0 24 24\">...</svg>", "all", nullptr, mkVisible("source_type", "inline"), "simple");
                addField("icon_id", "Icon (from Library)", "icon_browse", nullptr, "all", nullptr, mkVisible("source_type", "icon"), "simple");
                {
                    // Tells the icon_browse picker (properties.js) which sibling field
                    // holds the resolved SVG source — Render() only ever reads svgMarkup.
                    json_object *f = json_object_array_get_idx(arr, json_object_array_length(arr) - 1);
                    json_object_object_add(f, "svgTargetKey", json_object_new_string("svg_markup"));
                }
                addField("media_id", "Media (from MediaDB)", "media_browse", nullptr, "all", nullptr, mkVisible("source_type", "media"), "simple");
                addField("real_width", "Preview Width (px)", "number", nullptr, "all", nullptr, mkVisible("source_type", "media"));
                addField("real_height", "Preview Height (px)", "number", nullptr, "all", nullptr, mkVisible("source_type", "media"));
                addField("alt_text", "Alt Text", "text", nullptr, "all", nullptr, nullptr, "simple");
                addField("color", "Color", "color", nullptr, "all", nullptr, mkVisible("source_type", "inline"), "simple");
                addField("color", "Color", "color", nullptr, "all", nullptr,
                          mkVisibleAny("source_type", {"inline", "icon"}), "simple");
                addField("width", "Width", "text", "64px", "desktop");
                addField("height", "Height", "text", "64px", "desktop");
                addField("link_url", "Link URL", "text", "/page or https://...", "all", nullptr, nullptr, "simple");
@@ -638,7 +567,7 @@ extern "C" {
                    content.appendChild(img);
                } else {
                    std::string viewBox, inner;
                    parseSvgMarkup(svgMarkup, viewBox, inner);
                    svgmarkup::parse(svgMarkup, viewBox, inner);

                    libhtmlpp::SvgElement svgEl;
                    svgEl.setAttribute("xmlns", "http://www.w3.org/2000/svg");
+65 −3

File changed.

Preview size limit exceeded, changes collapsed.

Loading