Loading editor/html/index.html +2 −0 Original line number Diff line number Diff line Loading @@ -47,6 +47,8 @@ <button id="btn-move-down" data-i18n-title="I18N_MOVE_DOWN">↓</button> <button id="btn-indent" data-i18n-title="I18N_INDENT">→</button> <button id="btn-outdent" data-i18n-title="I18N_OUTDENT">←</button> <button id="btn-copy-el" data-i18n-title="I18N_COPY_ELEMENT">⎘</button> <button id="btn-paste-el" data-i18n-title="I18N_PASTE_ELEMENT">📋</button> <button id="btn-remove" data-i18n-title="I18N_REMOVE_ELEMENT">✖</button> </div> <div id="document-tree"></div> Loading editor/html/js/api.js +12 −0 Original line number Diff line number Diff line Loading @@ -125,6 +125,18 @@ var EditorApi = (function() { return request('POST', '/api/element/outdent', { uuid: uuid }); }, copyElement: function(uuid) { return request('POST', '/api/element/copy', { uuid: uuid }); }, pasteElement: function(xml, parentUuid, position) { return request('POST', '/api/element/paste', { xml: xml, parent_uuid: parentUuid || '', position: position || 'after' }); }, getProperties: function(uuid) { return request('GET', '/api/element/' + uuid + '/properties'); }, Loading editor/html/js/editor.js +70 −0 Original line number Diff line number Diff line Loading @@ -22,6 +22,7 @@ try { bindDialogs(); } catch(e) { console.error('bindDialogs:', e); } try { bindSettingsDialog(); } catch(e) { console.error('bindSettingsDialog:', e); } try { bindAiDialog(); } catch(e) { console.error('bindAiDialog:', e); } try { bindClipboardShortcuts(); } catch(e) { console.error('bindClipboardShortcuts:', e); } Preview.clear(); DocumentTree.setOnSelect(function(uuid) { Loading Loading @@ -89,6 +90,67 @@ }); } // --- Copy / paste elements --- var _clipboardXml = ''; function bindClipboardShortcuts() { document.addEventListener('keydown', function(e) { // Ignore when typing in an input, textarea or contenteditable field. var t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) { return; } if (!(e.ctrlKey || e.metaKey)) return; var key = (e.key || '').toLowerCase(); if (key === 'c') { if (DocumentTree.getSelectedUuid()) { e.preventDefault(); copySelected(); } } else if (key === 'v') { e.preventDefault(); pasteClipboard(); } }); } function copySelected() { var uuid = DocumentTree.getSelectedUuid(); if (!uuid) return; EditorApi.copyElement(uuid).then(function(resp) { _clipboardXml = resp.xml || ''; try { localStorage.setItem('webedit-clipboard', _clipboardXml); } catch(e) {} }).catch(function(err) { console.error('Failed to copy element:', err); alert(I18n.t('I18N_ACTION_FAILED', 'Aktion fehlgeschlagen') + ': ' + (err.error || I18n.t('I18N_COPY_FAILED', 'Kopieren fehlgeschlagen'))); }); } function pasteClipboard() { var xml = _clipboardXml; if (!xml) { try { xml = localStorage.getItem('webedit-clipboard') || ''; } catch(e) {} } if (!xml) return; var parentUuid = DocumentTree.getSelectedUuid() || ''; var position = ''; if (parentUuid) { position = DocumentTree.canSelectedHaveChildren() ? 'child' : 'after'; } EditorApi.pasteElement(xml, parentUuid, position).then(function() { refreshDocument(); }).catch(function(err) { console.error('Failed to paste element:', err); alert(I18n.t('I18N_ACTION_FAILED', 'Aktion fehlgeschlagen') + ': ' + (err.error || I18n.t('I18N_PASTE_FAILED', 'Einfügen fehlgeschlagen'))); }); } // --- Refresh document tree --- function refreshDocument() { Loading Loading @@ -232,6 +294,14 @@ EditorApi.outdent(uuid).then(refreshDocument); }); document.getElementById('btn-copy-el').addEventListener('click', function() { copySelected(); }); document.getElementById('btn-paste-el').addEventListener('click', function() { pasteClipboard(); }); document.getElementById('btn-remove').addEventListener('click', function() { var uuid = DocumentTree.getSelectedUuid(); if (!uuid) return; Loading editor/src/webedit_api.cpp +182 −0 Original line number Diff line number Diff line Loading @@ -113,6 +113,18 @@ bool webedit::Api::handleRequest(libhttppp::HttpRequest &curreq, const int tid, return true; } // Route: /api/element/copy — serialize a subtree to XML for the clipboard if (path == "/api/element/copy") { handleCopyElement(curreq, sessionid); return true; } // Route: /api/element/paste — insert clipboard XML relative to a target if (path == "/api/element/paste") { handlePasteElement(curreq, sessionid); return true; } // Route: /api/element/{uuid}/properties if (path.find("/api/element/") == 0 && path.ends_with("/properties")) { std::string uuid = path.substr(13); // after "/api/element/" Loading Loading @@ -970,6 +982,156 @@ void webedit::Api::handleImportXml(libhttppp::HttpRequest &curreq, json_object_put(req); } // --- Copy element (serialize subtree to clipboard XML) --- void webedit::Api::handleCopyElement(libhttppp::HttpRequest &curreq, const std::string &sessionid) { std::string body = getRequestBody(curreq); json_object *req = json_tokener_parse(body.c_str()); if (!req) { sendJsonError(curreq, 400, "Invalid JSON"); return; } json_object *uuidObj = nullptr; json_object_object_get_ex(req, "uuid", &uuidObj); const char *uuid = uuidObj ? json_object_get_string(uuidObj) : nullptr; if (!uuid) { json_object_put(req); sendJsonError(curreq, 400, "Missing uuid"); return; } auto &doc = getDocState(sessionid); std::lock_guard<std::mutex> lk(doc.mtx); auto *node = findElement(doc.root, uuid); if (!node) { json_object_put(req); sendJsonError(curreq, 404, "Element not found"); return; } std::string xml = serializeElementXml(node); json_object *resp = json_object_new_object(); json_object_object_add(resp, "status", json_object_new_string("ok")); json_object_object_add(resp, "xml", json_object_new_string(xml.c_str())); sendJson(curreq, resp); json_object_put(resp); json_object_put(req); } // --- Paste element (insert clipboard XML relative to a target) --- void webedit::Api::handlePasteElement(libhttppp::HttpRequest &curreq, const std::string &sessionid) { std::string body = getRequestBody(curreq); json_object *req = json_tokener_parse(body.c_str()); if (!req) { sendJsonError(curreq, 400, "Invalid JSON"); return; } json_object *xmlObj = nullptr, *parentObj = nullptr, *posObj = nullptr; json_object_object_get_ex(req, "xml", &xmlObj); json_object_object_get_ex(req, "parent_uuid", &parentObj); json_object_object_get_ex(req, "position", &posObj); const char *xml = xmlObj ? json_object_get_string(xmlObj) : nullptr; const char *parentUuid = parentObj ? json_object_get_string(parentObj) : nullptr; const char *position = posObj ? json_object_get_string(posObj) : "after"; if (!xml || strlen(xml) == 0) { json_object_put(req); sendJsonError(curreq, 400, "Missing xml"); return; } tinyxml2::XMLDocument xmlDoc; if (xmlDoc.Parse(xml) != tinyxml2::XML_SUCCESS) { json_object_put(req); sendJsonError(curreq, 400, "Invalid XML"); return; } auto *rootEl = xmlDoc.FirstChildElement("WebEditor"); if (!rootEl) rootEl = xmlDoc.FirstChildElement(); if (!rootEl) { json_object_put(req); sendJsonError(curreq, 400, "Empty XML document"); return; } auto &doc = getDocState(sessionid); std::lock_guard<std::mutex> lk(doc.mtx); // Build the chain of new elements from the clipboard XML. // New instances get fresh instance IDs (fromXmlObject does not read them), // so pasting never collides with existing elements. blogi::webedit::EditPlugin *first = nullptr; blogi::webedit::EditPlugin *last = nullptr; for (auto *child = rootEl->FirstChildElement(); child; child = child->NextSiblingElement()) { if (strcmp(child->Name(), "property") == 0) continue; const char *typeName = child->Name(); auto *el = createPluginByName(typeName, doc); if (!el) continue; el->fromXmlObject(child); loadChildrenFromXml(child, el, doc); if (!first) first = el; if (last) last->setNextEl(el); last = el; } if (!first) { json_object_put(req); sendJsonError(curreq, 400, "Nothing to paste"); return; } // Determine insertion point. blogi::webedit::EditPlugin *target = (parentUuid && strlen(parentUuid) > 0) ? findElement(doc.root, parentUuid) : nullptr; if (!target) { // No selection: append to the top-level chain. if (!doc.root) { doc.root = first; } else { auto *tail = doc.root; while (tail->nextElement()) tail = const_cast<blogi::webedit::EditPlugin*>(tail->nextElement()); tail->setNextEl(first); } } else if (strcmp(position, "child") == 0 && target->canHaveChildren()) { // Insert as last child of target. if (!target->getChildElement()) { target->setChildElement(*first); } else { auto *tail = const_cast<blogi::webedit::EditPlugin*>(target->getChildElement()); while (tail->nextElement()) tail = const_cast<blogi::webedit::EditPlugin*>(tail->nextElement()); tail->setNextEl(first); } } else { // Insert as sibling directly after target, preserving the chain. auto *oldNext = const_cast<blogi::webedit::EditPlugin*>(target->nextElement()); target->setNextEl(first); last->setNextEl(oldNext); } json_object *resp = json_object_new_object(); json_object_object_add(resp, "status", json_object_new_string("ok")); json_object_object_add(resp, "uuid", json_object_new_string(first->getInstanceId().c_str())); sendJson(curreq, resp); json_object_put(resp); json_object_put(req); } // --- Save document to DB --- void webedit::Api::handleSaveDocument(libhttppp::HttpRequest &curreq, Loading Loading @@ -2394,6 +2556,26 @@ std::string webedit::Api::exportTreeXml(DocumentState &doc) { return printer.CStr(); } std::string webedit::Api::serializeElementXml(blogi::webedit::EditPlugin *node) { tinyxml2::XMLDocument xmlDoc; auto *root = xmlDoc.NewElement("WebEditor"); root->SetAttribute("Version", "1.0"); root->SetAttribute("editor", "blogi-webedit"); xmlDoc.InsertEndChild(root); if (node) { // toXmlObject serializes the node and its children, but NOT its // following siblings — exactly the subtree we want for copy. auto *xmlEl = node->toXmlObject(&xmlDoc); if (xmlEl) root->InsertEndChild(xmlEl); } tinyxml2::XMLPrinter printer; xmlDoc.Print(&printer); return printer.CStr(); } blogi::webedit::EditPlugin *webedit::Api::findElement( blogi::webedit::EditPlugin *root, const std::string &uuid) { auto *el = root; Loading editor/src/webedit_api.h +3 −0 Original line number Diff line number Diff line Loading @@ -75,6 +75,8 @@ namespace webedit { const std::string &direction); void handleIndentElement(libhttppp::HttpRequest &curreq, const std::string &sessionid); void handleOutdentElement(libhttppp::HttpRequest &curreq, const std::string &sessionid); void handleCopyElement(libhttppp::HttpRequest &curreq, const std::string &sessionid); void handlePasteElement(libhttppp::HttpRequest &curreq, const std::string &sessionid); void handleGetProperties(libhttppp::HttpRequest &curreq, const std::string &sessionid, const std::string &uuid); void handleSetProperties(libhttppp::HttpRequest &curreq, const std::string &sessionid, Loading Loading @@ -148,6 +150,7 @@ namespace webedit { json_object *serializeTree(const blogi::webedit::EditPlugin *node); std::string renderTree(const blogi::webedit::EditPlugin *node); std::string exportTreeXml(DocumentState &doc); std::string serializeElementXml(blogi::webedit::EditPlugin *node); blogi::webedit::EditPlugin *findElement(blogi::webedit::EditPlugin *root, const std::string &uuid); Loading Loading
editor/html/index.html +2 −0 Original line number Diff line number Diff line Loading @@ -47,6 +47,8 @@ <button id="btn-move-down" data-i18n-title="I18N_MOVE_DOWN">↓</button> <button id="btn-indent" data-i18n-title="I18N_INDENT">→</button> <button id="btn-outdent" data-i18n-title="I18N_OUTDENT">←</button> <button id="btn-copy-el" data-i18n-title="I18N_COPY_ELEMENT">⎘</button> <button id="btn-paste-el" data-i18n-title="I18N_PASTE_ELEMENT">📋</button> <button id="btn-remove" data-i18n-title="I18N_REMOVE_ELEMENT">✖</button> </div> <div id="document-tree"></div> Loading
editor/html/js/api.js +12 −0 Original line number Diff line number Diff line Loading @@ -125,6 +125,18 @@ var EditorApi = (function() { return request('POST', '/api/element/outdent', { uuid: uuid }); }, copyElement: function(uuid) { return request('POST', '/api/element/copy', { uuid: uuid }); }, pasteElement: function(xml, parentUuid, position) { return request('POST', '/api/element/paste', { xml: xml, parent_uuid: parentUuid || '', position: position || 'after' }); }, getProperties: function(uuid) { return request('GET', '/api/element/' + uuid + '/properties'); }, Loading
editor/html/js/editor.js +70 −0 Original line number Diff line number Diff line Loading @@ -22,6 +22,7 @@ try { bindDialogs(); } catch(e) { console.error('bindDialogs:', e); } try { bindSettingsDialog(); } catch(e) { console.error('bindSettingsDialog:', e); } try { bindAiDialog(); } catch(e) { console.error('bindAiDialog:', e); } try { bindClipboardShortcuts(); } catch(e) { console.error('bindClipboardShortcuts:', e); } Preview.clear(); DocumentTree.setOnSelect(function(uuid) { Loading Loading @@ -89,6 +90,67 @@ }); } // --- Copy / paste elements --- var _clipboardXml = ''; function bindClipboardShortcuts() { document.addEventListener('keydown', function(e) { // Ignore when typing in an input, textarea or contenteditable field. var t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) { return; } if (!(e.ctrlKey || e.metaKey)) return; var key = (e.key || '').toLowerCase(); if (key === 'c') { if (DocumentTree.getSelectedUuid()) { e.preventDefault(); copySelected(); } } else if (key === 'v') { e.preventDefault(); pasteClipboard(); } }); } function copySelected() { var uuid = DocumentTree.getSelectedUuid(); if (!uuid) return; EditorApi.copyElement(uuid).then(function(resp) { _clipboardXml = resp.xml || ''; try { localStorage.setItem('webedit-clipboard', _clipboardXml); } catch(e) {} }).catch(function(err) { console.error('Failed to copy element:', err); alert(I18n.t('I18N_ACTION_FAILED', 'Aktion fehlgeschlagen') + ': ' + (err.error || I18n.t('I18N_COPY_FAILED', 'Kopieren fehlgeschlagen'))); }); } function pasteClipboard() { var xml = _clipboardXml; if (!xml) { try { xml = localStorage.getItem('webedit-clipboard') || ''; } catch(e) {} } if (!xml) return; var parentUuid = DocumentTree.getSelectedUuid() || ''; var position = ''; if (parentUuid) { position = DocumentTree.canSelectedHaveChildren() ? 'child' : 'after'; } EditorApi.pasteElement(xml, parentUuid, position).then(function() { refreshDocument(); }).catch(function(err) { console.error('Failed to paste element:', err); alert(I18n.t('I18N_ACTION_FAILED', 'Aktion fehlgeschlagen') + ': ' + (err.error || I18n.t('I18N_PASTE_FAILED', 'Einfügen fehlgeschlagen'))); }); } // --- Refresh document tree --- function refreshDocument() { Loading Loading @@ -232,6 +294,14 @@ EditorApi.outdent(uuid).then(refreshDocument); }); document.getElementById('btn-copy-el').addEventListener('click', function() { copySelected(); }); document.getElementById('btn-paste-el').addEventListener('click', function() { pasteClipboard(); }); document.getElementById('btn-remove').addEventListener('click', function() { var uuid = DocumentTree.getSelectedUuid(); if (!uuid) return; Loading
editor/src/webedit_api.cpp +182 −0 Original line number Diff line number Diff line Loading @@ -113,6 +113,18 @@ bool webedit::Api::handleRequest(libhttppp::HttpRequest &curreq, const int tid, return true; } // Route: /api/element/copy — serialize a subtree to XML for the clipboard if (path == "/api/element/copy") { handleCopyElement(curreq, sessionid); return true; } // Route: /api/element/paste — insert clipboard XML relative to a target if (path == "/api/element/paste") { handlePasteElement(curreq, sessionid); return true; } // Route: /api/element/{uuid}/properties if (path.find("/api/element/") == 0 && path.ends_with("/properties")) { std::string uuid = path.substr(13); // after "/api/element/" Loading Loading @@ -970,6 +982,156 @@ void webedit::Api::handleImportXml(libhttppp::HttpRequest &curreq, json_object_put(req); } // --- Copy element (serialize subtree to clipboard XML) --- void webedit::Api::handleCopyElement(libhttppp::HttpRequest &curreq, const std::string &sessionid) { std::string body = getRequestBody(curreq); json_object *req = json_tokener_parse(body.c_str()); if (!req) { sendJsonError(curreq, 400, "Invalid JSON"); return; } json_object *uuidObj = nullptr; json_object_object_get_ex(req, "uuid", &uuidObj); const char *uuid = uuidObj ? json_object_get_string(uuidObj) : nullptr; if (!uuid) { json_object_put(req); sendJsonError(curreq, 400, "Missing uuid"); return; } auto &doc = getDocState(sessionid); std::lock_guard<std::mutex> lk(doc.mtx); auto *node = findElement(doc.root, uuid); if (!node) { json_object_put(req); sendJsonError(curreq, 404, "Element not found"); return; } std::string xml = serializeElementXml(node); json_object *resp = json_object_new_object(); json_object_object_add(resp, "status", json_object_new_string("ok")); json_object_object_add(resp, "xml", json_object_new_string(xml.c_str())); sendJson(curreq, resp); json_object_put(resp); json_object_put(req); } // --- Paste element (insert clipboard XML relative to a target) --- void webedit::Api::handlePasteElement(libhttppp::HttpRequest &curreq, const std::string &sessionid) { std::string body = getRequestBody(curreq); json_object *req = json_tokener_parse(body.c_str()); if (!req) { sendJsonError(curreq, 400, "Invalid JSON"); return; } json_object *xmlObj = nullptr, *parentObj = nullptr, *posObj = nullptr; json_object_object_get_ex(req, "xml", &xmlObj); json_object_object_get_ex(req, "parent_uuid", &parentObj); json_object_object_get_ex(req, "position", &posObj); const char *xml = xmlObj ? json_object_get_string(xmlObj) : nullptr; const char *parentUuid = parentObj ? json_object_get_string(parentObj) : nullptr; const char *position = posObj ? json_object_get_string(posObj) : "after"; if (!xml || strlen(xml) == 0) { json_object_put(req); sendJsonError(curreq, 400, "Missing xml"); return; } tinyxml2::XMLDocument xmlDoc; if (xmlDoc.Parse(xml) != tinyxml2::XML_SUCCESS) { json_object_put(req); sendJsonError(curreq, 400, "Invalid XML"); return; } auto *rootEl = xmlDoc.FirstChildElement("WebEditor"); if (!rootEl) rootEl = xmlDoc.FirstChildElement(); if (!rootEl) { json_object_put(req); sendJsonError(curreq, 400, "Empty XML document"); return; } auto &doc = getDocState(sessionid); std::lock_guard<std::mutex> lk(doc.mtx); // Build the chain of new elements from the clipboard XML. // New instances get fresh instance IDs (fromXmlObject does not read them), // so pasting never collides with existing elements. blogi::webedit::EditPlugin *first = nullptr; blogi::webedit::EditPlugin *last = nullptr; for (auto *child = rootEl->FirstChildElement(); child; child = child->NextSiblingElement()) { if (strcmp(child->Name(), "property") == 0) continue; const char *typeName = child->Name(); auto *el = createPluginByName(typeName, doc); if (!el) continue; el->fromXmlObject(child); loadChildrenFromXml(child, el, doc); if (!first) first = el; if (last) last->setNextEl(el); last = el; } if (!first) { json_object_put(req); sendJsonError(curreq, 400, "Nothing to paste"); return; } // Determine insertion point. blogi::webedit::EditPlugin *target = (parentUuid && strlen(parentUuid) > 0) ? findElement(doc.root, parentUuid) : nullptr; if (!target) { // No selection: append to the top-level chain. if (!doc.root) { doc.root = first; } else { auto *tail = doc.root; while (tail->nextElement()) tail = const_cast<blogi::webedit::EditPlugin*>(tail->nextElement()); tail->setNextEl(first); } } else if (strcmp(position, "child") == 0 && target->canHaveChildren()) { // Insert as last child of target. if (!target->getChildElement()) { target->setChildElement(*first); } else { auto *tail = const_cast<blogi::webedit::EditPlugin*>(target->getChildElement()); while (tail->nextElement()) tail = const_cast<blogi::webedit::EditPlugin*>(tail->nextElement()); tail->setNextEl(first); } } else { // Insert as sibling directly after target, preserving the chain. auto *oldNext = const_cast<blogi::webedit::EditPlugin*>(target->nextElement()); target->setNextEl(first); last->setNextEl(oldNext); } json_object *resp = json_object_new_object(); json_object_object_add(resp, "status", json_object_new_string("ok")); json_object_object_add(resp, "uuid", json_object_new_string(first->getInstanceId().c_str())); sendJson(curreq, resp); json_object_put(resp); json_object_put(req); } // --- Save document to DB --- void webedit::Api::handleSaveDocument(libhttppp::HttpRequest &curreq, Loading Loading @@ -2394,6 +2556,26 @@ std::string webedit::Api::exportTreeXml(DocumentState &doc) { return printer.CStr(); } std::string webedit::Api::serializeElementXml(blogi::webedit::EditPlugin *node) { tinyxml2::XMLDocument xmlDoc; auto *root = xmlDoc.NewElement("WebEditor"); root->SetAttribute("Version", "1.0"); root->SetAttribute("editor", "blogi-webedit"); xmlDoc.InsertEndChild(root); if (node) { // toXmlObject serializes the node and its children, but NOT its // following siblings — exactly the subtree we want for copy. auto *xmlEl = node->toXmlObject(&xmlDoc); if (xmlEl) root->InsertEndChild(xmlEl); } tinyxml2::XMLPrinter printer; xmlDoc.Print(&printer); return printer.CStr(); } blogi::webedit::EditPlugin *webedit::Api::findElement( blogi::webedit::EditPlugin *root, const std::string &uuid) { auto *el = root; Loading
editor/src/webedit_api.h +3 −0 Original line number Diff line number Diff line Loading @@ -75,6 +75,8 @@ namespace webedit { const std::string &direction); void handleIndentElement(libhttppp::HttpRequest &curreq, const std::string &sessionid); void handleOutdentElement(libhttppp::HttpRequest &curreq, const std::string &sessionid); void handleCopyElement(libhttppp::HttpRequest &curreq, const std::string &sessionid); void handlePasteElement(libhttppp::HttpRequest &curreq, const std::string &sessionid); void handleGetProperties(libhttppp::HttpRequest &curreq, const std::string &sessionid, const std::string &uuid); void handleSetProperties(libhttppp::HttpRequest &curreq, const std::string &sessionid, Loading Loading @@ -148,6 +150,7 @@ namespace webedit { json_object *serializeTree(const blogi::webedit::EditPlugin *node); std::string renderTree(const blogi::webedit::EditPlugin *node); std::string exportTreeXml(DocumentState &doc); std::string serializeElementXml(blogi::webedit::EditPlugin *node); blogi::webedit::EditPlugin *findElement(blogi::webedit::EditPlugin *root, const std::string &uuid); Loading