Commit 0d3a5b45 authored by Jan Köster's avatar Jan Köster
Browse files

test

parent bd130239
Loading
Loading
Loading
Loading
+18 −0
Original line number Diff line number Diff line
@@ -42,6 +42,7 @@
            <button id="btn-export-xml" data-i18n-title="I18N_TITLE_EXPORT_XML">&#x2B06; <span data-i18n="I18N_EXPORT_XML">Export XML</span></button>
            <button id="btn-import-html" data-i18n-title="I18N_IMPORT_HTML">&#x2B07; <span data-i18n="I18N_IMPORT_HTML">Import HTML</span></button>
            <button id="btn-export-html" data-i18n-title="I18N_EXPORT_HTML">&#x2B06; <span data-i18n="I18N_EXPORT_HTML">Export HTML</span></button>
            <button id="btn-import-css" data-i18n-title="I18N_IMPORT_CSS">&#x2B07; <span data-i18n="I18N_IMPORT_CSS">Import CSS</span></button>
            <button id="btn-survey-manager" title="Umfragen verwalten">&#x1F4CB; <span>Umfragen</span></button>
        </div>
        <div class="toolbar-group">
@@ -351,6 +352,23 @@
        </div>
    </dialog>

    <dialog id="import-css-dialog">
        <h3 data-i18n="I18N_IMPORT_CSS">CSS importieren</h3>
        <div class="set-field">
            <input type="url" id="import-css-url" data-i18n-placeholder="I18N_IMPORT_CSS_URL_PLACEHOLDER" placeholder="https://beispiel.de/style.css">
            <button type="button" id="btn-import-css-load-url" class="set-save-btn" data-i18n="I18N_IMPORT_CSS_LOAD_URL">Von URL laden</button>
        </div>
        <p id="import-css-url-status" class="set-status" style="display:none"></p>
        <p class="hint" style="text-align:center" data-i18n="I18N_IMPORT_HTML_OR">oder</p>
        <label class="file-upload-label">&#x1F4C2; <span data-i18n="I18N_UPLOAD_FILE">Datei w&auml;hlen</span><input type="file" id="import-css-file" accept=".css,.txt" style="display:none"></label>
        <textarea id="import-css-content" rows="15" placeholder="CSS hier einf&uuml;gen..."></textarea>
        <p class="hint" data-i18n="I18N_IMPORT_CSS_HINT">Nur global g&uuml;ltige Regeln (:root, html, body, *, ...) werden &uuml;bernommen.</p>
        <div class="dialog-actions">
            <button id="btn-import-css-confirm" data-i18n="I18N_IMPORT_CSS">Importieren</button>
            <button id="btn-import-css-cancel" data-i18n="I18N_CANCEL">Abbrechen</button>
        </div>
    </dialog>

    <dialog id="export-html-dialog">
        <h3 data-i18n="I18N_EXPORT_HTML">HTML exportieren</h3>
        <textarea id="export-html-content" rows="15" readonly></textarea>
+11 −0
Original line number Diff line number Diff line
@@ -558,6 +558,17 @@ var EditorApi = (function() {
            return request('GET', '/api/document/export-html');
        },

        // Imports a standalone stylesheet (pasted/uploaded text, or fetched
        // server-side from url) and merges its :root/html/body/*-scoped
        // rules into the document's page-level GlobalCss. Pass exactly one
        // of css/url.
        importCss: function(css, url) {
            var data = {};
            if (url) data.url = url;
            else data.css = css;
            return request('POST', '/api/document/import-css', data);
        },

        listSections: function() {
            return request('GET', '/api/document/sections');
        },
+85 −1
Original line number Diff line number Diff line
@@ -9,7 +9,7 @@
    var currentDocId = '';
    var currentDocGroup = '';
    var currentDocSeo = {};
    var currentDocGlobalCss = ''; // page-level CSS collected by HTML import, see doc-seo-dialog
    var currentDocGlobalCss = ''; // page-level CSS collected by HTML/CSS import, see doc-seo-dialog
    var currentDocGlobalJs = ''; // page-level JS collected from external <script src> during HTML import
    var currentDocPublishParams = {}; // target name -> last-used publish params for this template
    var currentDocLastPublishTarget = ''; // target name last successfully published to, see applyPreferredPublishTarget
@@ -388,6 +388,10 @@
            openImportHtmlDialog();
        });

        document.getElementById('btn-import-css').addEventListener('click', function() {
            openImportCssDialog();
        });

        document.getElementById('btn-export-html').addEventListener('click', function() {
            EditorApi.exportHtml().then(function(resp) {
                document.getElementById('export-html-content').value = resp.html || '';
@@ -551,6 +555,36 @@
        el.className = 'set-status' + (isError ? ' error' : '');
    }

    // --- CSS import dialog (paste/upload/URL) -- unlike HTML import there is
    // no widget tree/block picker: the result always merges straight into the
    // document's page-level GlobalCss (see handleImportCss), so both the
    // paste/upload and URL paths import directly and close the dialog.

    function openImportCssDialog() {
        document.getElementById('import-css-content').value = '';
        document.getElementById('import-css-url').value = '';
        setImportCssUrlStatus('');
        document.getElementById('import-css-dialog').showModal();
    }

    function setImportCssUrlStatus(text, isError) {
        var el = document.getElementById('import-css-url-status');
        el.textContent = text || '';
        el.style.display = text ? '' : 'none';
        el.className = 'set-status' + (isError ? ' error' : '');
    }

    function finishImportCss(promise) {
        return promise.then(function(resp) {
            document.getElementById('import-css-dialog').close();
            refreshDocument();
            if (!resp.global_css) {
                alert(I18n.t('I18N_IMPORT_CSS_NO_GLOBAL_RULES',
                    'Keine global gültigen Regeln (:root, html, body, *, ...) gefunden.'));
            }
        });
    }

    // Fetches @p url server-side (via EditorApi.parseHtmlUrl) and feeds the
    // result into the same block-picker step used by paste/upload, so the
    // rest of the import flow (candidate tree, media-import options, confirm)
@@ -1057,6 +1091,56 @@
            document.getElementById('import-html-dialog').close();
        });

        // CSS Import dialog: import from URL (fetched server-side, then
        // merged into GlobalCss immediately -- no block picker step, unlike
        // HTML import's URL path).
        document.getElementById('btn-import-css-load-url').addEventListener('click', function() {
            var url = document.getElementById('import-css-url').value.trim();
            if (!url) return;
            var btn = this;
            btn.disabled = true;
            setImportCssUrlStatus(I18n.t('I18N_LOADING_URL', 'URL wird geladen...'));
            finishImportCss(EditorApi.importCss(null, url)).then(function() {
                btn.disabled = false;
                setImportCssUrlStatus('');
            }).catch(function(err) {
                btn.disabled = false;
                setImportCssUrlStatus(I18n.t('I18N_IMPORT_FAILED') + ': ' + (err.error || ''), true);
            });
        });

        document.getElementById('import-css-url').addEventListener('keydown', function(e) {
            if (e.key === 'Enter') {
                e.preventDefault();
                document.getElementById('btn-import-css-load-url').click();
            }
        });

        document.getElementById('import-css-file').addEventListener('change', function() {
            var file = this.files[0];
            this.value = '';
            if (!file) return;
            var reader = new FileReader();
            reader.onload = function(e) {
                var css = e.target.result;
                if (!css.trim()) return;
                document.getElementById('import-css-content').value = css;
            };
            reader.readAsText(file);
        });

        document.getElementById('btn-import-css-confirm').addEventListener('click', function() {
            var css = document.getElementById('import-css-content').value;
            if (!css.trim()) return;
            finishImportCss(EditorApi.importCss(css, null)).catch(function(err) {
                alert(I18n.t('I18N_IMPORT_FAILED') + ': ' + (err.error || ''));
            });
        });

        document.getElementById('btn-import-css-cancel').addEventListener('click', function() {
            document.getElementById('import-css-dialog').close();
        });

        // HTML Export dialog
        document.getElementById('btn-export-html-copy').addEventListener('click', function() {
            var textarea = document.getElementById('export-html-content');
+120 −80
Original line number Diff line number Diff line
@@ -1904,6 +1904,102 @@ libhtmlpp::Element *resolveImportPath(libhtmlpp::Element *root, const std::strin
    return cur;
}

// Scans every rule in @p sheet and appends the serialized text of any rule
// scoped to :root/html/body/*/a pseudo-element (see the isGlobal checks
// below) to @p outGlobalCss -- used both for convertHtmlToWidgetXml's
// GlobalCss/rootEnv extraction and convertCssToGlobalCss's standalone-CSS
// import, so the two stay consistent about what counts as "global" CSS.
// If @p outRootEnv is non-null, any "--name" custom-property declaration
// found on a global rule is also recorded there (see
// resolveCustomProperties for how this seeds per-element inheritance) --
// callers that have no widget tree to seed (e.g. convertCssToGlobalCss)
// pass nullptr and skip that bookkeeping.
void extractGlobalCssFromStylesheet(const libhtmlpp::CSSStyleSheet &sheet,
                                     std::string &outGlobalCss,
                                     std::map<std::string,std::string> *outRootEnv)
{
    for (const auto &rule : sheet.getRules()) {
        const std::string &sel = rule.getSelector();
        if (sel.empty()) continue;

        bool isGlobal = false;
        // Only meaningful (and only set) when sel[0] == '@': the at-rule's
        // own "@media (...)"/"@supports (...)" wrapper and the selector
        // nested inside it. libhtmlpp::CSSStyleSheet flattens e.g.
        // "@media (max-width:768px) { :root { ... } }" into ONE CSSRule
        // whose getSelector() is the whole "@media (max-width:768px) :root"
        // string -- see the reconstruction below for why that matters.
        std::string atWrapper, innerSel;

        if (sel[0] == '@') {
            size_t parenDepth = 0;
            size_t splitPos = std::string::npos;
            for (size_t i = 0; i < sel.size(); ++i) {
                if (sel[i] == '(') ++parenDepth;
                else if (sel[i] == ')') {
                    if (parenDepth > 0) --parenDepth;
                    if (parenDepth == 0) { splitPos = i + 1; break; }
                }
            }
            if (splitPos != std::string::npos && splitPos + 1 < sel.size()) {
                size_t innerStart = sel.find_first_not_of(" \t\n\r", splitPos);
                if (innerStart != std::string::npos) {
                    atWrapper = sel.substr(0, splitPos);
                    innerSel = sel.substr(innerStart);
                    std::string innerLower = innerSel;
                    std::transform(innerLower.begin(), innerLower.end(),
                        innerLower.begin(),
                        [](unsigned char c) { return std::tolower(c); });
                    if (innerLower.compare(0, 5, ":root") == 0 ||
                        innerLower.find("::") != std::string::npos ||
                        innerLower == "*" || innerLower == "html" ||
                        innerLower == "body") {
                        isGlobal = true;
                    }
                }
            } else {
                isGlobal = true;
            }
        } else {
            std::string selLower = sel;
            std::transform(selLower.begin(), selLower.end(), selLower.begin(),
                [](unsigned char c) { return std::tolower(c); });
            if (selLower == ":root" || selLower == "*" || selLower == "html" ||
                selLower == "body" || selLower.compare(0, 5, ":root") == 0 ||
                selLower.find("::") != std::string::npos) {
                isGlobal = true;
            }
        }

        if (isGlobal && outRootEnv) {
            for (const auto &prop : rule.getDeclaration().getProperties()) {
                if (prop.getName().compare(0, 2, "--") == 0) {
                    (*outRootEnv)[prop.getName()] = prop.getValue();
                }
            }
        }

        if (isGlobal && sel[0] == '@' && !innerSel.empty()) {
            // rule.serialize() just wraps getSelector() in a single "{ }"
            // pair -- fine for a plain selector, but here getSelector() is
            // the FULL flattened "@media (...) :root" string, so that would
            // emit "@media (...) :root { decls }", which is invalid CSS (the
            // at-rule needs its own wrapping braces around the nested rule).
            // Reconstruct the proper nested form instead, the same way
            // libhtmlpp::CSSStyleSheet::collectApproximateMatches does for
            // per-element @media matches.
            outGlobalCss += atWrapper + " {\n  " + innerSel + " {\n";
            for (const auto &prop : rule.getDeclaration().getProperties()) {
                outGlobalCss += "    " + prop.getName() + ": " + prop.getValue() + ";\n";
            }
            outGlobalCss += "  }\n}\n";
        } else if (isGlobal) {
            outGlobalCss += rule.serialize(true);
            outGlobalCss += "\n";
        }
    }
}

} // namespace

std::vector<blogi::htmlimport::ImportNode>
@@ -2165,86 +2261,7 @@ bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html,
    // document-order rule for the same name simply overwrites an earlier
    // one, same "last one wins" approximation used elsewhere here.
    std::map<std::string,std::string> rootEnv;
    for (const auto &rule : stylesheet.getRules()) {
        const std::string &sel = rule.getSelector();
        if (sel.empty()) continue;

        bool isGlobal = false;
        // Only meaningful (and only set) when sel[0] == '@': the at-rule's
        // own "@media (...)"/"@supports (...)" wrapper and the selector
        // nested inside it. libhtmlpp::CSSStyleSheet flattens e.g.
        // "@media (max-width:768px) { :root { ... } }" into ONE CSSRule
        // whose getSelector() is the whole "@media (max-width:768px) :root"
        // string -- see the reconstruction below for why that matters.
        std::string atWrapper, innerSel;

        if (sel[0] == '@') {
            size_t parenDepth = 0;
            size_t splitPos = std::string::npos;
            for (size_t i = 0; i < sel.size(); ++i) {
                if (sel[i] == '(') ++parenDepth;
                else if (sel[i] == ')') {
                    if (parenDepth > 0) --parenDepth;
                    if (parenDepth == 0) { splitPos = i + 1; break; }
                }
            }
            if (splitPos != std::string::npos && splitPos + 1 < sel.size()) {
                size_t innerStart = sel.find_first_not_of(" \t\n\r", splitPos);
                if (innerStart != std::string::npos) {
                    atWrapper = sel.substr(0, splitPos);
                    innerSel = sel.substr(innerStart);
                    std::string innerLower = innerSel;
                    std::transform(innerLower.begin(), innerLower.end(),
                        innerLower.begin(),
                        [](unsigned char c) { return std::tolower(c); });
                    if (innerLower.compare(0, 5, ":root") == 0 ||
                        innerLower.find("::") != std::string::npos ||
                        innerLower == "*" || innerLower == "html" ||
                        innerLower == "body") {
                        isGlobal = true;
                    }
                }
            } else {
                isGlobal = true;
            }
        } else {
            std::string selLower = sel;
            std::transform(selLower.begin(), selLower.end(), selLower.begin(),
                [](unsigned char c) { return std::tolower(c); });
            if (selLower == ":root" || selLower == "*" || selLower == "html" ||
                selLower == "body" || selLower.compare(0, 5, ":root") == 0 ||
                selLower.find("::") != std::string::npos) {
                isGlobal = true;
            }
        }

        if (isGlobal) {
            for (const auto &prop : rule.getDeclaration().getProperties()) {
                if (prop.getName().compare(0, 2, "--") == 0) {
                    rootEnv[prop.getName()] = prop.getValue();
                }
            }
        }

        if (isGlobal && sel[0] == '@' && !innerSel.empty()) {
            // rule.serialize() just wraps getSelector() in a single "{ }"
            // pair -- fine for a plain selector, but here getSelector() is
            // the FULL flattened "@media (...) :root" string, so that would
            // emit "@media (...) :root { decls }", which is invalid CSS (the
            // at-rule needs its own wrapping braces around the nested rule).
            // Reconstruct the proper nested form instead, the same way
            // libhtmlpp::CSSStyleSheet::collectApproximateMatches does for
            // per-element @media matches.
            globalCss += atWrapper + " {\n  " + innerSel + " {\n";
            for (const auto &prop : rule.getDeclaration().getProperties()) {
                globalCss += "    " + prop.getName() + ": " + prop.getValue() + ";\n";
            }
            globalCss += "  }\n}\n";
        } else if (isGlobal) {
            globalCss += rule.serialize(true);
            globalCss += "\n";
        }
    }
    extractGlobalCssFromStylesheet(stylesheet, globalCss, &rootEnv);

    if (!globalCss.empty()) {
        xmlRoot->SetAttribute("GlobalCss", globalCss.c_str());
@@ -2279,3 +2296,26 @@ bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html,
    outGlobalJs = globalJs;
    return true;
}

bool blogi::htmlimport::convertCssToGlobalCss(const std::string &css,
                                               std::string &outGlobalCss,
                                               const std::string &baseUrl)
{
    std::string cssText = css;
    // Same rationale as the <style>-block handling in convertHtmlToWidgetXml:
    // url(...) here is relative to wherever this stylesheet came from (the
    // fetched URL, if any) -- resolve it before parsing so the CSSRule
    // declarations blogi ends up storing are already absolute.
    if (!baseUrl.empty()) {
        cssText = rewriteCssUrls(cssText,
            [&](const std::string &ref) { return resolveUrl(baseUrl, ref); });
    }

    libhtmlpp::CSSStyleSheet stylesheet;
    stylesheet.parse(cssText);

    std::string globalCss;
    extractGlobalCssFromStylesheet(stylesheet, globalCss, nullptr);
    outGlobalCss = globalCss;
    return true;
}
+17 −0
Original line number Diff line number Diff line
@@ -173,5 +173,22 @@ namespace htmlimport {
                                ImportStats *outStats = nullptr,
                                const std::string &baseUrl = "");

    /** Convert a standalone CSS stylesheet (pasted/uploaded/fetched on its
     *  own, not part of an HTML document) into global CSS text, using the
     *  same :root/html/body/*-scoped rule extraction convertHtmlToWidgetXml
     *  applies to a document's own <style>/<link> content (see
     *  extractGlobalCssFromStylesheet in htmlimport.cpp). Non-global rules
     *  (e.g. plain element/class selectors) are parsed but discarded --
     *  there is no widget tree here for them to attach to. If @p baseUrl is
     *  non-empty (the CSS was fetched from a URL rather than pasted/
     *  uploaded), url(...) references are resolved against it first, same
     *  as an external stylesheet's own url(...) in convertHtmlToWidgetXml.
     *  Always returns true; malformed CSS simply yields fewer/no rules
     *  rather than failing (same best-effort parsing convertHtmlToWidgetXml
     *  relies on for a document's own <style> blocks). */
    bool convertCssToGlobalCss(const std::string &css,
                               std::string &outGlobalCss,
                               const std::string &baseUrl = "");

} // namespace htmlimport
} // namespace blogi
Loading