Commit 08d8bfc5 authored by jan.koester's avatar jan.koester
Browse files

better editor

parent 412ac978
Loading
Loading
Loading
Loading
+29 −6
Changes for editor/html/css/editor.css: 29 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -1768,21 +1768,44 @@ dialog label {
    border-radius: 4px;
}

.import-html-candidate {
.import-html-node > .import-html-node-header {
    border-bottom: 1px solid var(--border);
}

.import-html-node:last-child > .import-html-node-header {
    border-bottom: none;
}

.import-html-node.collapsed > .import-html-children {
    display: none;
}

.import-html-node-header {
    display: flex;
    align-items: baseline;
    gap: 8px;
    padding: 6px 8px;
    border-bottom: 1px solid var(--border);
    cursor: pointer;
}

.import-html-candidate:last-child {
    border-bottom: none;
.import-html-node-header:hover {
    background: var(--bg-hover);
}

.import-html-candidate:hover {
    background: var(--bg-hover);
.import-html-node-toggle {
    flex: 0 0 auto;
    width: 14px;
    font-size: 10px;
    text-align: center;
    color: var(--text-muted);
}

.import-html-node-toggle.has-children {
    cursor: pointer;
}

.import-html-children {
    padding-left: 16px;
}

.import-html-candidate-tag {
+60 −17
Changes for editor/html/js/editor.js: 60 added lines, 17 removed lines.
Original line number Diff line number Diff line
@@ -578,41 +578,84 @@
        });
    }

    function renderImportHtmlCandidates(candidates) {
    function renderImportHtmlCandidates(nodes) {
        var container = document.getElementById('import-html-candidates');
        container.innerHTML = '';
        for (var i = 0; i < candidates.length; i++) {
            var cand = candidates[i];
            var label = document.createElement('label');
            label.className = 'import-html-candidate';
        for (var i = 0; i < nodes.length; i++) {
            container.appendChild(renderImportHtmlNode(nodes[i], 0));
        }
    }

    // Recursively renders one picker-tree node (see ImportNode in
    // htmlimport.h) plus its children. Children start collapsed so the
    // first paint matches the old flat top-level-only picker; expanding
    // lets the user drill down and pick an element at any depth instead of
    // only whole top-level blocks. Each row's checkbox is independent of
    // its ancestors/descendants -- checking both a block and something
    // nested inside it imports that nested part twice (see
    // convertHtmlToWidgetXml's selectedPaths doc comment).
    function renderImportHtmlNode(node, depth) {
        var hasChildren = !!(node.children && node.children.length > 0);

        var wrap = document.createElement('div');
        wrap.className = 'import-html-node' + (hasChildren ? ' collapsed' : '');

        var header = document.createElement('label');
        header.className = 'import-html-node-header';

        var toggle = document.createElement('span');
        toggle.className = 'import-html-node-toggle';
        if (hasChildren) {
            toggle.textContent = '▶';
            toggle.classList.add('has-children');
            toggle.addEventListener('click', function(e) {
                e.preventDefault();
                e.stopPropagation();
                var collapsed = wrap.classList.toggle('collapsed');
                toggle.textContent = collapsed ? '▶' : '▼';
            });
        } else {
            toggle.textContent = '·';
        }
        header.appendChild(toggle);

        var checkbox = document.createElement('input');
        checkbox.type = 'checkbox';
            checkbox.checked = true;
            checkbox.setAttribute('data-index', cand.index);
            label.appendChild(checkbox);
        checkbox.checked = depth === 0;
        checkbox.setAttribute('data-path', node.path);
        header.appendChild(checkbox);

        var tag = document.createElement('span');
        tag.className = 'import-html-candidate-tag';
            tag.textContent = cand.tag || '?';
            label.appendChild(tag);
        tag.textContent = node.tag || '?';
        header.appendChild(tag);

            var meta = (cand.css_id ? '#' + cand.css_id : '') +
                       (cand.css_class ? '.' + cand.css_class.split(' ').join('.') : '');
        var meta = (node.css_id ? '#' + node.css_id : '') +
                   (node.css_class ? '.' + node.css_class.split(' ').join('.') : '');
        if (meta) {
            var metaEl = document.createElement('span');
            metaEl.className = 'import-html-candidate-meta';
            metaEl.textContent = meta;
                label.appendChild(metaEl);
            header.appendChild(metaEl);
        }

        var preview = document.createElement('span');
        preview.className = 'import-html-candidate-preview';
            preview.textContent = cand.preview || '';
            label.appendChild(preview);
        preview.textContent = node.preview || '';
        header.appendChild(preview);

            container.appendChild(label);
        wrap.appendChild(header);

        if (hasChildren) {
            var childrenWrap = document.createElement('div');
            childrenWrap.className = 'import-html-children';
            for (var i = 0; i < node.children.length; i++) {
                childrenWrap.appendChild(renderImportHtmlNode(node.children[i], depth + 1));
            }
            wrap.appendChild(childrenWrap);
        }

        return wrap;
    }

    function setImportHtmlCandidatesChecked(checked) {
@@ -779,7 +822,7 @@
                sections = [];
                var boxes = document.querySelectorAll('#import-html-candidates input[type=checkbox]');
                for (var i = 0; i < boxes.length; i++) {
                    if (boxes[i].checked) sections.push(parseInt(boxes[i].getAttribute('data-index'), 10));
                    if (boxes[i].checked) sections.push(boxes[i].getAttribute('data-path'));
                }
            }
            EditorApi.importHtml(html, null, null, sections).then(function() {
+103 −49
Changes for editor/src/htmlimport.cpp: 103 added lines, 49 removed lines.
Original line number Diff line number Diff line
@@ -290,8 +290,8 @@ void blogi::htmlimport::htmlElementToWidgetXml(
    // stop is only honored for the initial (htmlEl, parentXml) frame -- every
    // frame pushed onto childFrames below defaults stop to nullptr, so
    // recursing into an element's own children is never truncated. This lets
    // callers process a single top-level sibling in isolation (see
    // convertHtmlToWidgetXml's selectedIndexes handling) without mutating
    // callers process a single node (at any depth) in isolation (see
    // convertHtmlToWidgetXml's selectedPaths handling) without mutating
    // the parsed document tree.
    struct Frame { libhtmlpp::Element *cur; tinyxml2::XMLElement *pXml; libhtmlpp::Element *stop = nullptr; };
    std::stack<Frame> workStack;
@@ -832,11 +832,12 @@ void blogi::htmlimport::htmlElementToWidgetXml(

namespace {

// Collects, in document order, the elements the HTML-import block picker
// should offer as top-level candidates: direct children of <body> (after
// transparently unwrapping <html>), or top-level fragment elements when the
// input has no <html>/<body> wrapper at all. <head> and its children are
// always excluded.
// Collects, in document order, the "importable" element children of a given
// node: direct children of <body> (after transparently unwrapping <html>) at
// the document root, or a node's own direct children when called deeper in
// the import tree -- <head> and its children are always excluded. Used both
// to build the import picker's tree (buildImportNode, recursively) and to
// resolve an ImportNode::path back to an Element* (resolveImportPath).
void collectTopLevelElements(libhtmlpp::Element *node,
                              std::vector<libhtmlpp::Element*> &out)
{
@@ -891,38 +892,13 @@ void collectPlainText(libhtmlpp::Element *node, std::string &out, size_t maxLen)
    }
}

} // namespace
// How deep the import picker tree recurses -- a safety net against
// pathologically deep/malformed input HTML; nodes past this depth are still
// importable individually, just not expanded any further in the picker.
const int kMaxImportTreeDepth = 12;

std::vector<blogi::htmlimport::ImportCandidate>
blogi::htmlimport::listTopLevelCandidates(const std::string &html)
std::string collapseWhitespace(const std::string &text, size_t maxLen)
{
    std::vector<ImportCandidate> result;

    libhtmlpp::HtmlPage page;
    libhtmlpp::HtmlElement index;
    try {
        page.loadString(index, html);
    } catch (...) {
        return result;
    }

    std::vector<libhtmlpp::Element*> topLevel;
    collectTopLevelElements(&index, topLevel);

    for (size_t i = 0; i < topLevel.size(); ++i) {
        libhtmlpp::HtmlElement *el = static_cast<libhtmlpp::HtmlElement*>(topLevel[i]);
        std::string tag = el->getTagname();
        std::transform(tag.begin(), tag.end(), tag.begin(),
            [](unsigned char c) { return std::tolower(c); });

        ImportCandidate cand;
        cand.index = static_cast<int>(i);
        cand.tag = tag;
        cand.cssClass = el->getAtributte("class");
        cand.cssId = el->getAtributte("id");

        std::string text;
        collectPlainText(el->firstChild(), text, 160);
    std::string collapsed;
    bool lastSpace = false;
    for (char c : text) {
@@ -934,13 +910,93 @@ blogi::htmlimport::listTopLevelCandidates(const std::string &html)
            lastSpace = false;
        }
    }
        if (collapsed.size() > 80) {
            collapsed.resize(80);
    if (collapsed.size() > maxLen) {
        collapsed.resize(maxLen);
        collapsed += "...";
    }
        cand.preview = collapsed;
    return collapsed;
}

blogi::htmlimport::ImportNode buildImportNode(libhtmlpp::Element *node,
                                               const std::string &path,
                                               int depth)
{
    libhtmlpp::HtmlElement *el = static_cast<libhtmlpp::HtmlElement*>(node);
    std::string tag = el->getTagname();
    std::transform(tag.begin(), tag.end(), tag.begin(),
        [](unsigned char c) { return std::tolower(c); });

    blogi::htmlimport::ImportNode result;
    result.path = path;
    result.tag = tag;
    result.cssClass = el->getAtributte("class");
    result.cssId = el->getAtributte("id");

    std::string text;
    collectPlainText(el->firstChild(), text, 160);
    result.preview = collapseWhitespace(text, 80);

    if (depth < kMaxImportTreeDepth) {
        std::vector<libhtmlpp::Element*> children;
        collectTopLevelElements(el->firstChild(), children);
        for (size_t i = 0; i < children.size(); ++i) {
            result.children.push_back(
                buildImportNode(children[i], path + "." + std::to_string(i), depth + 1));
        }
    }

    return result;
}

// Resolves a dot-joined path (e.g. "0.2.1", see ImportNode::path) back to
// the Element* it names, by re-running the exact same per-level traversal
// (collectTopLevelElements) buildImportNode used to assign that path in the
// first place -- deterministic as long as it's the same HTML string.
libhtmlpp::Element *resolveImportPath(libhtmlpp::Element *root, const std::string &path)
{
    std::vector<libhtmlpp::Element*> level;
    collectTopLevelElements(root, level);

    libhtmlpp::Element *cur = nullptr;
    size_t start = 0;
    while (start <= path.size()) {
        size_t dot = path.find('.', start);
        std::string part = path.substr(start, dot == std::string::npos ? std::string::npos : dot - start);

        int idx = -1;
        try { idx = std::stoi(part); } catch (...) { return nullptr; }
        if (idx < 0 || static_cast<size_t>(idx) >= level.size()) return nullptr;

        cur = level[idx];
        if (dot == std::string::npos) break;

        level.clear();
        collectTopLevelElements(static_cast<libhtmlpp::HtmlElement*>(cur)->firstChild(), level);
        start = dot + 1;
    }
    return cur;
}

} // namespace

std::vector<blogi::htmlimport::ImportNode>
blogi::htmlimport::buildImportTree(const std::string &html)
{
    std::vector<ImportNode> result;

    libhtmlpp::HtmlPage page;
    libhtmlpp::HtmlElement index;
    try {
        page.loadString(index, html);
    } catch (...) {
        return result;
    }

    std::vector<libhtmlpp::Element*> topLevel;
    collectTopLevelElements(&index, topLevel);

        result.push_back(cand);
    for (size_t i = 0; i < topLevel.size(); ++i) {
        result.push_back(buildImportNode(topLevel[i], std::to_string(i), 0));
    }

    return result;
@@ -949,7 +1005,7 @@ blogi::htmlimport::listTopLevelCandidates(const std::string &html)
bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html,
                                               std::string &outXml,
                                               std::string &outGlobalCss,
                                               const std::set<int> *selectedIndexes)
                                               const std::set<std::string> *selectedPaths)
{
    libhtmlpp::HtmlPage page;
    libhtmlpp::HtmlElement index;
@@ -1078,13 +1134,11 @@ bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html,
    }

    if (root) {
        if (selectedIndexes) {
            std::vector<libhtmlpp::Element*> topLevel;
            collectTopLevelElements(root, topLevel);
            for (size_t i = 0; i < topLevel.size(); ++i) {
                if (selectedIndexes->count(static_cast<int>(i))) {
                    htmlElementToWidgetXml(topLevel[i], xmlDoc, xmlRoot, stylesheet,
                                           topLevel[i]->nextElement());
        if (selectedPaths) {
            for (const auto &path : *selectedPaths) {
                libhtmlpp::Element *el = resolveImportPath(root, path);
                if (el) {
                    htmlElementToWidgetXml(el, xmlDoc, xmlRoot, stylesheet, el->nextElement());
                }
            }
        } else {
+22 −15
Changes for editor/src/htmlimport.h: 22 added lines, 15 removed lines.
Original line number Diff line number Diff line
@@ -67,33 +67,40 @@ namespace htmlimport {
        const libhtmlpp::CSSStyleSheet &sheet,
        libhtmlpp::Element *stopBefore = nullptr);

    /** One HTML-import "top-level candidate": a direct child of <body> (after
     *  transparently unwrapping <html>/<head>), or a top-level fragment
     *  element when the input has no <html>/<body> wrapper at all. Used by
     *  the import dialog's block picker so the user can choose which
     *  top-level blocks to bring in before conversion. */
    struct ImportCandidate {
        int index = 0;
    /** One node of the HTML-import block picker's tree: an element reachable
     *  from the document root by transparently unwrapping <html>/<body> and
     *  dropping <head> (see collectTopLevelElements in htmlimport.cpp),
     *  recursively down through its own children. `path` is a dot-joined
     *  sequence of per-level ordinal indices (e.g. "0.2.1") that uniquely
     *  and deterministically identifies this node for a given HTML string --
     *  see resolveImportPath, which uses the identical traversal rule to
     *  turn a path back into an Element* on a fresh parse of the same HTML. */
    struct ImportNode {
        std::string path;
        std::string tag;
        std::string cssClass;
        std::string cssId;
        std::string preview;
        std::vector<ImportNode> children;
    };

    /** Parse @p html and list its top-level candidates (see ImportCandidate)
     *  without converting anything to widget XML. */
    std::vector<ImportCandidate> listTopLevelCandidates(const std::string &html);
    /** Parse @p html and build its full import tree (see ImportNode) without
     *  converting anything to widget XML. */
    std::vector<ImportNode> buildImportTree(const std::string &html);

    /** Convert raw HTML string to a widget XML string (<WebEditor>...).
     *  Returns true on success, writes the XML to @p outXml and optional
     *  global CSS to @p outGlobalCss. If @p selectedIndexes is non-null, only
     *  the top-level candidates (see listTopLevelCandidates) whose index is
     *  in the set are converted/imported; otherwise every top-level block is
     *  imported (today's behavior). */
     *  global CSS to @p outGlobalCss. If @p selectedPaths is non-null, only
     *  the nodes (see ImportNode::path, at any depth) named in the set are
     *  converted/imported, each as its own top-level element -- otherwise
     *  the whole document is imported (today's behavior). Selecting both an
     *  ancestor and one of its descendants imports the descendant twice
     *  (once as part of the ancestor's subtree, once standalone); this is
     *  intentionally not deduplicated -- deselect the ancestor instead. */
    bool convertHtmlToWidgetXml(const std::string &html,
                                std::string &outXml,
                                std::string &outGlobalCss,
                                const std::set<int> *selectedIndexes = nullptr);
                                const std::set<std::string> *selectedPaths = nullptr);

} // namespace htmlimport
} // namespace blogi
+32 −18
Changes for editor/src/webedit_api.cpp: 32 added lines, 18 removed lines.
Original line number Diff line number Diff line
@@ -5061,9 +5061,28 @@ void webedit::Api::handleMediaBrowse(libhttppp::HttpRequest &curreq,

// ====================== HTML Import/Export ======================

// Recursively serializes one ImportNode (and its children) for the
// parse-html response -- same shape as the document tree JSON
// (handleGetDocument's "tree", see serializeTree) but with "path" instead
// of "uuid", since these nodes don't have widget instances yet.
static json_object *importNodeToJson(const blogi::htmlimport::ImportNode &node) {
    json_object *obj = json_object_new_object();
    json_object_object_add(obj, "path", json_object_new_string(node.path.c_str()));
    json_object_object_add(obj, "tag", json_object_new_string(node.tag.c_str()));
    json_object_object_add(obj, "css_class", json_object_new_string(node.cssClass.c_str()));
    json_object_object_add(obj, "css_id", json_object_new_string(node.cssId.c_str()));
    json_object_object_add(obj, "preview", json_object_new_string(node.preview.c_str()));
    json_object *children = json_object_new_array();
    for (const auto &child : node.children) {
        json_object_array_add(children, importNodeToJson(child));
    }
    json_object_object_add(obj, "children", children);
    return obj;
}

// Parses html without importing anything -- lets the import dialog show a
// picker of top-level candidates (see listTopLevelCandidates) so the user
// can choose which blocks to bring in before handleImportHtml commits them.
// picker tree (see buildImportTree) so the user can choose which blocks/
// elements, at any depth, to bring in before handleImportHtml commits them.
void webedit::Api::handleParseHtml(libhttppp::HttpRequest &curreq,
                                    const std::string &sessionid) {
    (void)sessionid;
@@ -5083,21 +5102,15 @@ void webedit::Api::handleParseHtml(libhttppp::HttpRequest &curreq,
        return;
    }

    auto candidates = blogi::htmlimport::listTopLevelCandidates(html);
    auto tree = blogi::htmlimport::buildImportTree(html);

    std::string widgetXml, globalCss;
    blogi::htmlimport::convertHtmlToWidgetXml(html, widgetXml, globalCss);

    json_object *resp = json_object_new_object();
    json_object *arr = json_object_new_array();
    for (const auto &cand : candidates) {
        json_object *c = json_object_new_object();
        json_object_object_add(c, "index", json_object_new_int(cand.index));
        json_object_object_add(c, "tag", json_object_new_string(cand.tag.c_str()));
        json_object_object_add(c, "css_class", json_object_new_string(cand.cssClass.c_str()));
        json_object_object_add(c, "css_id", json_object_new_string(cand.cssId.c_str()));
        json_object_object_add(c, "preview", json_object_new_string(cand.preview.c_str()));
        json_object_array_add(arr, c);
    for (const auto &node : tree) {
        json_object_array_add(arr, importNodeToJson(node));
    }
    json_object_object_add(resp, "candidates", arr);
    if (!globalCss.empty())
@@ -5133,24 +5146,25 @@ void webedit::Api::handleImportHtml(libhttppp::HttpRequest &curreq,
        return;
    }

    // Optional: only import the top-level candidates the import dialog's
    // block picker selected (see handleParseHtml/listTopLevelCandidates).
    // Absent/empty means "import everything" (today's behavior).
    std::set<int> selectedIndexes;
    // Optional: only import the nodes (at any depth, see ImportNode::path)
    // the import dialog's picker tree selected (see handleParseHtml/
    // buildImportTree). Absent/empty means "import everything" (today's
    // behavior).
    std::set<std::string> selectedPaths;
    bool haveSelection = false;
    if (sectionsObj && json_object_is_type(sectionsObj, json_type_array)) {
        haveSelection = true;
        size_t n = json_object_array_length(sectionsObj);
        for (size_t i = 0; i < n; ++i) {
            json_object *idxObj = json_object_array_get_idx(sectionsObj, i);
            if (idxObj) selectedIndexes.insert(json_object_get_int(idxObj));
            json_object *pathObj = json_object_array_get_idx(sectionsObj, i);
            if (pathObj) selectedPaths.insert(json_object_get_string(pathObj));
        }
    }

    // Convert HTML to widget XML via htmlimport
    std::string widgetXml, globalCss;
    if (!blogi::htmlimport::convertHtmlToWidgetXml(html, widgetXml, globalCss,
                                                    haveSelection ? &selectedIndexes : nullptr)) {
                                                    haveSelection ? &selectedPaths : nullptr)) {
        json_object_put(req);
        sendJsonError(curreq, 500, "HTML conversion failed");
        return;