Files
rose-ash/shared/static/scripts/sx-editor.js
giles 8ceb9aee62 Eliminate raw HTML injection: convert ~kg-html/captions to native sx
Add shared/sx/html_to_sx.py (HTMLParser-based HTML→sx converter) and
update lexical_to_sx.py so HTML cards, markdown cards, and captions all
produce native sx expressions instead of opaque HTML strings.

- ~kg-html now wraps native sx children (editor can identify the block)
- New ~kg-md component for markdown card blocks
- Captions are sx expressions, not escaped HTML strings
- kg_cards.sx: replace (raw! caption) with direct caption rendering
- sx-editor.js: htmlToSx() via DOMParser, serializeInline for captions,
  _childrenSx for ~kg-html/~kg-md, new kg-md edit UI
- Migration script (blog/scripts/migrate_sx_html.py) to re-convert
  stored sx_content from lexical source

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:57:27 +00:00

2464 lines
86 KiB
JavaScript

/**
* SX Block Editor — a Ghost/Koenig-style block editor that stores sx source.
*
* Matches Koenig's UX: single hover + button on empty paragraphs, slash
* commands, full card edit modes, inline format toolbar, keyboard shortcuts,
* drag-drop uploads, oEmbed/bookmark metadata fetching.
*
* Usage:
* var handle = SxEditor.mount('sx-editor', {
* initialSx: '(<> (p "Hello") (h2 "Title"))',
* csrfToken: '...',
* uploadUrls: { image: '/upload/image', media: '/upload/media', file: '/upload/file' },
* oembedUrl: '/api/oembed',
* onChange: function(sx) { ... }
* });
* handle.getSx(); // current sx source
* handle.destroy(); // teardown
*/
(function () {
"use strict";
// =========================================================================
// Constants
// =========================================================================
var TEXT_TAGS = { p: true, h2: true, h3: true, blockquote: true };
var LIST_TAGS = { ul: true, ol: true };
var INLINE_MAP = {
bold: "strong", italic: "em", strikethrough: "s", underline: "u", code: "code"
};
// Card menu items grouped like Koenig
var CARD_MENU = [
{ section: "Primary", items: [
{ type: "image", icon: "fa-regular fa-image", label: "Image", desc: "Upload, or embed with /image [url]", slash: ["image", "img"] },
{ type: "gallery", icon: "fa-regular fa-images", label: "Gallery", desc: "Create an image gallery", slash: ["gallery"] },
{ type: "video", icon: "fa-solid fa-video", label: "Video", desc: "Upload and play a video", slash: ["video"] },
{ type: "audio", icon: "fa-solid fa-headphones", label: "Audio", desc: "Upload and play audio", slash: ["audio"] },
{ type: "file", icon: "fa-regular fa-file", label: "File", desc: "Upload a downloadable file", slash: ["file"] },
]},
{ section: "Text", items: [
{ type: "html", icon: "fa-solid fa-code", label: "HTML", desc: "Insert raw HTML", slash: ["html"] },
{ type: "hr", icon: "fa-solid fa-minus", label: "Divider", desc: "Insert a dividing line", slash: ["divider", "hr"] },
{ type: "callout", icon: "fa-regular fa-comment-dots",label: "Callout", desc: "Info box that stands out", slash: ["callout"] },
{ type: "toggle", icon: "fa-solid fa-caret-down", label: "Toggle", desc: "Collapsible content", slash: ["toggle"] },
{ type: "code", icon: "fa-solid fa-terminal", label: "Code", desc: "Insert a code block", slash: ["code", "codeblock"] },
]},
{ section: "Embed", items: [
{ type: "bookmark", icon: "fa-solid fa-bookmark", label: "Bookmark",desc: "Embed a link as a visual bookmark", slash: ["bookmark"] },
{ type: "embed", icon: "fa-solid fa-link", label: "Other...",desc: "Embed a URL via oEmbed", slash: ["embed", "youtube", "vimeo", "twitter", "spotify", "codepen"] },
{ type: "button", icon: "fa-solid fa-square", label: "Button", desc: "Add a button", slash: ["button", "cta"] },
]},
];
// Build flat list for slash matching
var ALL_CARD_ITEMS = [];
for (var s = 0; s < CARD_MENU.length; s++) {
for (var j = 0; j < CARD_MENU[s].items.length; j++) {
ALL_CARD_ITEMS.push(CARD_MENU[s].items[j]);
}
}
// =========================================================================
// Helpers
// =========================================================================
function el(tag, attrs, children) {
var node = document.createElement(tag);
if (attrs) {
for (var k in attrs) {
if (k === "className") node.className = attrs[k];
else if (k.indexOf("on") === 0) node.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
else node.setAttribute(k, attrs[k]);
}
}
if (children != null) {
if (typeof children === "string") node.textContent = children;
else if (Array.isArray(children)) {
for (var i = 0; i < children.length; i++) {
if (children[i] != null) node.appendChild(
typeof children[i] === "string" ? document.createTextNode(children[i]) : children[i]
);
}
} else {
node.appendChild(typeof children === "string" ? document.createTextNode(children) : children);
}
}
return node;
}
function escSx(s) {
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function escHtml(s) {
var d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
// Void elements that have no closing tag
var VOID_ELEMENTS = {
area:1, base:1, br:1, col:1, embed:1, hr:1, img:1, input:1,
link:1, meta:1, param:1, source:1, track:1, wbr:1
};
// Boolean HTML attributes
var BOOLEAN_ATTRS = {
async:1, autofocus:1, autoplay:1, checked:1, controls:1,
default:1, defer:1, disabled:1, formnovalidate:1, hidden:1,
inert:1, ismap:1, loop:1, multiple:1, muted:1, nomodule:1,
novalidate:1, open:1, playsinline:1, readonly:1, required:1,
reversed:1, selected:1
};
/**
* Convert an HTML string to sx source using the browser's DOM parser.
*/
function htmlToSx(html) {
if (!html || !html.trim()) return '""';
var doc = new DOMParser().parseFromString("<body>" + html + "</body>", "text/html");
var body = doc.body;
// Collect non-whitespace-only root nodes
var roots = [];
for (var i = 0; i < body.childNodes.length; i++) {
var n = body.childNodes[i];
if (n.nodeType === 3 && !n.textContent.trim()) continue; // skip ws-only text at root
roots.push(n);
}
if (!roots.length) return '""';
if (roots.length === 1) return nodeToSx(roots[0]);
var parts = [];
for (var i = 0; i < roots.length; i++) parts.push(nodeToSx(roots[i]));
return "(<> " + parts.join(" ") + ")";
}
function nodeToSx(node) {
if (node.nodeType === 3) {
return '"' + escSx(node.textContent) + '"';
}
if (node.nodeType === 8) return ""; // comment
if (node.nodeType !== 1) return "";
var tag = node.tagName.toLowerCase();
var parts = [tag];
// Attributes
for (var i = 0; i < node.attributes.length; i++) {
var a = node.attributes[i];
if (BOOLEAN_ATTRS[a.name]) {
parts.push(":" + a.name + " true");
} else {
parts.push(':' + a.name + ' "' + escSx(a.value) + '"');
}
}
if (VOID_ELEMENTS[tag]) return "(" + parts.join(" ") + ")";
// Children
var children = [];
for (var i = 0; i < node.childNodes.length; i++) {
var s = nodeToSx(node.childNodes[i]);
if (s) children.push(s);
}
if (children.length) return "(" + parts.join(" ") + " " + children.join(" ") + ")";
return "(" + parts.join(" ") + ")";
}
/**
* Render sx children expressions back to an HTML string (for the HTML card textarea).
*/
function sxChildrenToHtml(childrenSx) {
if (!childrenSx || !childrenSx.trim()) return "";
try {
var rendered = Sx.render(childrenSx);
if (rendered instanceof Node) {
var div = document.createElement("div");
div.appendChild(rendered);
return div.innerHTML;
}
return String(rendered);
} catch (e) {
return childrenSx;
}
}
/**
* Serialize a parsed sx expression (from Sx.parse) back to sx source string.
* Used to capture children of ~kg-html/~kg-md cards from the parsed tree.
*/
function serializeExpr(expr) {
if (typeof expr === "string") return '"' + escSx(expr) + '"';
if (expr && expr.constructor === Sx.Keyword) return ":" + expr.name;
if (expr && expr.name !== undefined && expr.constructor !== Sx.Keyword) return expr.name;
if (Array.isArray(expr)) {
var parts = [];
for (var i = 0; i < expr.length; i++) parts.push(serializeExpr(expr[i]));
return "(" + parts.join(" ") + ")";
}
if (expr === true) return "true";
if (expr === false) return "false";
if (expr === null || expr === undefined) return "nil";
return String(expr);
}
function closestBlock(node, container) {
while (node && node !== container) {
if (node.hasAttribute && node.hasAttribute("data-sx-block")) return node;
node = node.parentNode;
}
return null;
}
function getBlockIndex(container, block) {
var blocks = container.querySelectorAll("[data-sx-block]");
for (var i = 0; i < blocks.length; i++) {
if (blocks[i] === block) return i;
}
return -1;
}
function focusEnd(editable) {
if (!editable) return;
editable.focus();
var range = document.createRange();
range.selectNodeContents(editable);
range.collapse(false);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
function formatFileSize(bytes) {
if (!bytes) return "";
var kb = bytes / 1024;
if (kb < 1024) return Math.round(kb) + " KB";
return (kb / 1024).toFixed(1) + " MB";
}
function formatDuration(seconds) {
if (!seconds) return "0:00";
var m = Math.floor(seconds / 60);
var s = Math.floor(seconds % 60);
return m + ":" + (s < 10 ? "0" : "") + s;
}
// =========================================================================
// DOM → SX serialization
// =========================================================================
function serializeInline(node) {
var parts = [];
for (var i = 0; i < node.childNodes.length; i++) {
var child = node.childNodes[i];
if (child.nodeType === 3) {
var text = child.textContent;
if (text) parts.push('"' + escSx(text) + '"');
} else if (child.nodeType === 1) {
var tag = child.tagName.toLowerCase();
if (tag === "br") {
parts.push('"\\n"');
} else if (tag === "a") {
var href = child.getAttribute("href") || "";
var inner = serializeInline(child);
parts.push('(a :href "' + escSx(href) + '" ' + inner + ')');
} else if (tag === "strong" || tag === "b") {
parts.push("(strong " + serializeInline(child) + ")");
} else if (tag === "em" || tag === "i") {
parts.push("(em " + serializeInline(child) + ")");
} else if (tag === "s" || tag === "strike" || tag === "del") {
parts.push("(s " + serializeInline(child) + ")");
} else if (tag === "u") {
parts.push("(u " + serializeInline(child) + ")");
} else if (tag === "code") {
parts.push("(code " + serializeInline(child) + ")");
} else if (tag === "sub") {
parts.push("(sub " + serializeInline(child) + ")");
} else if (tag === "sup") {
parts.push("(sup " + serializeInline(child) + ")");
} else if (tag === "span") {
parts.push(serializeInline(child));
} else {
parts.push('"' + escSx(child.textContent || "") + '"');
}
}
}
return parts.join(" ") || '""';
}
function serializeList(blockEl) {
var tag = blockEl.getAttribute("data-sx-tag");
var lis = blockEl.querySelectorAll("[data-sx-li]");
var items = [];
for (var i = 0; i < lis.length; i++) {
items.push("(li " + serializeInline(lis[i]) + ")");
}
return "(" + tag + " " + items.join(" ") + ")";
}
function serializeBlocks(container) {
var blocks = container.querySelectorAll("[data-sx-block]");
var parts = [];
for (var i = 0; i < blocks.length; i++) {
var block = blocks[i];
var tag = block.getAttribute("data-sx-tag");
if (block.hasAttribute("data-sx-card")) {
parts.push(serializeCard(block));
} else if (tag === "hr") {
parts.push("(hr)");
} else if (tag === "pre") {
var codeEl = block.querySelector("textarea, code");
var code = codeEl ? (codeEl.value !== undefined ? codeEl.value : codeEl.textContent) : "";
var lang = block.getAttribute("data-sx-lang") || "";
var langAttr = lang ? ' :class "language-' + escSx(lang) + '"' : "";
parts.push('(pre (code' + langAttr + ' "' + escSx(code) + '"))');
} else if (LIST_TAGS[tag]) {
parts.push(serializeList(block));
} else if (TEXT_TAGS[tag]) {
var editable = block.querySelector("[contenteditable]");
if (editable) {
parts.push("(" + tag + " " + serializeInline(editable) + ")");
}
}
}
if (parts.length === 0) return '(<> (p ""))';
if (parts.length === 1) return parts[0];
return "(<>\n " + parts.join("\n ") + ")";
}
// Cards whose children are positional sx args (not kwargs)
var CHILDREN_CARDS = { "kg-html": true, "kg-md": true };
function serializeCard(block) {
var cardType = block.getAttribute("data-sx-card");
var attrsJson = block.getAttribute("data-sx-attrs") || "{}";
var attrs;
try { attrs = JSON.parse(attrsJson); } catch (e) { attrs = {}; }
// Serialize caption as inline sx from the contenteditable
var captionEl = block.querySelector("[data-sx-caption]");
if (captionEl) {
var captionHtml = captionEl.innerHTML.trim();
if (captionHtml && captionEl.textContent.trim()) {
attrs.caption = serializeInline(captionEl);
} else {
delete attrs.caption;
}
}
var parts = ["(~" + cardType];
for (var k in attrs) {
if (attrs[k] === null || attrs[k] === undefined || attrs[k] === false) continue;
if (k === "caption") {
// Caption is already an sx expression string
parts.push(":caption " + attrs[k]);
continue;
}
if (k === "_childrenSx") continue; // handled below
if (attrs[k] === true) {
parts.push(":" + k + " true");
} else {
parts.push(':' + k + ' "' + escSx(String(attrs[k])) + '"');
}
}
// Append children for cards like ~kg-html, ~kg-md
if (CHILDREN_CARDS[cardType] && attrs._childrenSx) {
parts.push(attrs._childrenSx);
}
parts.push(")");
return parts.join(" ");
}
// =========================================================================
// SX → DOM deserialization
// =========================================================================
function deserializeSx(source) {
if (!source || !source.trim()) return [];
var expr;
try { expr = Sx.parse(source); } catch (e) {
console.error("sx-editor: parse error", e);
return [];
}
var blocks = [];
if (Array.isArray(expr) && expr[0] && expr[0].name === "<>") {
for (var i = 1; i < expr.length; i++) {
var b = exprToBlock(expr[i]);
if (b) blocks.push(b);
}
} else {
var b = exprToBlock(expr);
if (b) blocks.push(b);
}
return blocks;
}
function exprToBlock(expr) {
if (!Array.isArray(expr) || !expr[0]) return null;
var head = expr[0];
var tag = head.name || (typeof head === "string" ? head : "");
if (TEXT_TAGS[tag]) {
return createTextBlock(tag, renderInlineToHtml(expr.slice(1)));
}
if (LIST_TAGS[tag]) {
return createListBlock(tag, expr.slice(1));
}
if (tag === "hr") return createHrBlock();
if (tag === "pre") {
var codeExpr = expr[1];
var code = "", lang = "";
if (Array.isArray(codeExpr) && codeExpr[0] && codeExpr[0].name === "code") {
for (var i = 1; i < codeExpr.length; i++) {
if (codeExpr[i] && codeExpr[i].name === "class") {
var cls = codeExpr[i + 1] || "";
var m = cls.match(/language-(\w+)/);
if (m) lang = m[1];
i++;
} else if (typeof codeExpr[i] === "string") {
code = codeExpr[i];
}
}
}
return createCodeBlock(code, lang);
}
if (tag.charAt(0) === "~") {
var cardType = tag.slice(1);
var args = expr.slice(1);
var attrs = extractKwargs(args);
// For children-based cards, capture positional children as sx source
if (CHILDREN_CARDS[cardType]) {
var children = extractChildren(args);
if (children.length) {
var childParts = [];
for (var ci = 0; ci < children.length; ci++) childParts.push(serializeExpr(children[ci]));
attrs._childrenSx = childParts.join(" ");
}
}
// Convert caption from parsed sx expression back to sx source string
if (attrs.caption !== undefined && attrs.caption !== null) {
attrs.caption = serializeExpr(attrs.caption);
}
return createCardBlock(cardType, attrs);
}
return null;
}
function renderInlineToHtml(exprs) {
var html = "";
for (var i = 0; i < exprs.length; i++) {
var e = exprs[i];
if (typeof e === "string") {
html += escHtml(e);
} else if (Array.isArray(e) && e[0]) {
var t = e[0].name || "";
if (t === "a") {
var kw = extractKwargs(e.slice(1));
var rest = extractChildren(e.slice(1));
html += '<a href="' + escHtml(kw.href || "") + '">' + renderInlineToHtml(rest) + "</a>";
} else if (t === "strong" || t === "em" || t === "s" || t === "u" || t === "code" || t === "sub" || t === "sup") {
html += "<" + t + ">" + renderInlineToHtml(e.slice(1)) + "</" + t + ">";
} else {
html += escHtml(sxToString(e));
}
}
}
return html;
}
function extractKwargs(args) {
var kw = {};
for (var i = 0; i < args.length; i++) {
if (args[i] && typeof args[i] === "object" && args[i].name && args[i].constructor &&
args[i].constructor === Sx.Keyword) {
kw[args[i].name] = args[i + 1];
i++;
}
}
return kw;
}
function extractChildren(args) {
var children = [];
for (var i = 0; i < args.length; i++) {
if (args[i] && typeof args[i] === "object" && args[i].name && args[i].constructor &&
args[i].constructor === Sx.Keyword) {
i++;
} else {
children.push(args[i]);
}
}
return children;
}
function sxToString(expr) {
if (typeof expr === "string") return expr;
if (Array.isArray(expr)) return expr.map(sxToString).join("");
return String(expr);
}
// =========================================================================
// Block creation
// =========================================================================
function createTextBlock(tag, htmlContent) {
var wrapper = el("div", {
className: "sx-block sx-block-text",
"data-sx-block": "true",
"data-sx-tag": tag
});
var editable = el("div", {
contenteditable: "true",
className: "sx-block-content sx-editable" + (tag === "h2" || tag === "h3" ? " sx-heading" : "") +
(tag === "blockquote" ? " sx-quote" : ""),
"data-placeholder": tag === "p" ? "Type / for commands..." :
tag === "blockquote" ? "Type a quote..." :
"Type a heading...",
"data-block-type": tag
});
editable.innerHTML = htmlContent || "";
wrapper.appendChild(editable);
return wrapper;
}
function createListBlock(tag, items) {
var wrapper = el("div", {
className: "sx-block sx-block-list",
"data-sx-block": "true",
"data-sx-tag": tag
});
var listEl = el("div", {
className: "sx-block-content sx-list-content",
"data-list-type": tag
});
if (items && items.length) {
for (var i = 0; i < items.length; i++) {
var item = items[i];
if (Array.isArray(item) && item[0] && item[0].name === "li") {
var li = el("div", {
contenteditable: "true",
className: "sx-list-item sx-editable",
"data-sx-li": "true",
"data-placeholder": "List item..."
});
li.innerHTML = renderInlineToHtml(item.slice(1));
listEl.appendChild(li);
}
}
}
if (!listEl.children.length) {
var li = el("div", {
contenteditable: "true",
className: "sx-list-item sx-editable",
"data-sx-li": "true",
"data-placeholder": "List item..."
});
listEl.appendChild(li);
}
wrapper.appendChild(listEl);
return wrapper;
}
function createHrBlock() {
var wrapper = el("div", {
className: "sx-block sx-block-hr",
"data-sx-block": "true",
"data-sx-tag": "hr"
});
wrapper.appendChild(el("hr", { className: "sx-hr" }));
return wrapper;
}
function createCodeBlock(code, lang) {
var wrapper = el("div", {
className: "sx-block sx-block-code",
"data-sx-block": "true",
"data-sx-tag": "pre",
"data-sx-lang": lang || ""
});
var header = el("div", { className: "sx-code-header" });
var langInput = el("input", {
type: "text",
className: "sx-code-lang",
placeholder: "Language",
value: lang || ""
});
langInput.addEventListener("input", function () {
wrapper.setAttribute("data-sx-lang", langInput.value);
});
header.appendChild(langInput);
var textarea = el("textarea", {
className: "sx-code-textarea",
spellcheck: "false",
placeholder: "Paste or type code..."
}, code || "");
function autoResize() {
textarea.style.height = "auto";
textarea.style.height = textarea.scrollHeight + "px";
}
textarea.addEventListener("input", autoResize);
setTimeout(autoResize, 0);
wrapper.appendChild(header);
wrapper.appendChild(textarea);
return wrapper;
}
// =========================================================================
// Card blocks with edit/preview modes
// =========================================================================
/**
* Create a card block. Cards have two modes:
* - Preview mode: rendered ~kg-* component output
* - Edit mode: card-specific editing UI
*
* Click to enter edit mode, click outside to return to preview.
*/
function createCardBlock(cardType, attrs) {
attrs = attrs || {};
var wrapper = el("div", {
className: "sx-block sx-block-card",
"data-sx-block": "true",
"data-sx-tag": "card",
"data-sx-card": cardType,
"data-sx-attrs": JSON.stringify(attrs)
});
var preview = el("div", { className: "sx-card-preview" });
var editPanel = el("div", { className: "sx-card-edit", style: "display:none" });
// Render preview
renderCardPreview(cardType, attrs, preview);
// Build edit UI based on card type
buildCardEditUI(cardType, attrs, editPanel, wrapper, preview);
// Card toolbar (delete, width controls)
var toolbar = el("div", { className: "sx-card-toolbar" });
var deleteBtn = el("button", {
type: "button", className: "sx-card-tool-btn", title: "Delete card"
});
deleteBtn.innerHTML = '<i class="fa-solid fa-trash-can"></i>';
toolbar.appendChild(deleteBtn);
wrapper.appendChild(toolbar);
wrapper.appendChild(preview);
wrapper.appendChild(editPanel);
// Click preview → enter edit mode
preview.addEventListener("click", function (e) {
if (e.target.closest("[data-sx-caption]")) return; // caption is always editable
wrapper.classList.add("sx-card-editing");
preview.style.display = "none";
editPanel.style.display = "block";
// Focus first input in edit panel
var firstInput = editPanel.querySelector("input, textarea, [contenteditable]");
if (firstInput) firstInput.focus();
});
// Add caption for applicable card types
var captionTypes = {
"kg-image": true, "kg-gallery": true, "kg-embed": true,
"kg-bookmark": true, "kg-video": true, "kg-file": true
};
if (captionTypes[cardType]) {
var captionEl = el("div", {
contenteditable: "true",
className: "sx-card-caption sx-editable",
"data-sx-caption": "true",
"data-placeholder": "Type caption for image (optional)"
});
if (attrs.caption) {
// Caption is an sx expression string — render to HTML for the contenteditable
captionEl.innerHTML = sxChildrenToHtml(attrs.caption);
}
wrapper.appendChild(captionEl);
}
return wrapper;
}
function renderCardPreview(cardType, attrs, container) {
container.innerHTML = "";
try {
var sxSource = buildCardSx(cardType, attrs);
var dom = Sx.render(sxSource);
container.appendChild(dom);
} catch (e) {
container.innerHTML = '<div class="sx-card-fallback">[' + escHtml(cardType) + ' card]</div>';
}
}
function buildCardSx(cardType, attrs) {
var parts = ["(~" + cardType];
for (var k in attrs) {
if (attrs[k] === null || attrs[k] === undefined || attrs[k] === false || attrs[k] === "") continue;
if (k === "caption") {
// Caption is an sx expression, not a quoted string
parts.push(":caption " + attrs[k]);
continue;
}
if (k === "_childrenSx") continue; // handled below
if (attrs[k] === true) {
parts.push(":" + k + " true");
} else {
parts.push(':' + k + ' "' + escSx(String(attrs[k])) + '"');
}
}
// Append children for ~kg-html, ~kg-md
if (CHILDREN_CARDS[cardType] && attrs._childrenSx) {
parts.push(attrs._childrenSx);
}
parts.push(")");
return parts.join(" ");
}
function updateCardAttrs(wrapper, newAttrs) {
wrapper.setAttribute("data-sx-attrs", JSON.stringify(newAttrs));
}
function exitCardEdit(wrapper) {
var preview = wrapper.querySelector(".sx-card-preview");
var editPanel = wrapper.querySelector(".sx-card-edit");
var attrs;
try { attrs = JSON.parse(wrapper.getAttribute("data-sx-attrs") || "{}"); } catch (e) { attrs = {}; }
var cardType = wrapper.getAttribute("data-sx-card");
wrapper.classList.remove("sx-card-editing");
renderCardPreview(cardType, attrs, preview);
preview.style.display = "block";
editPanel.style.display = "none";
}
// =========================================================================
// Card edit UIs
// =========================================================================
function buildCardEditUI(cardType, attrs, editPanel, wrapper, previewEl) {
switch (cardType) {
case "kg-image": buildImageEditUI(attrs, editPanel, wrapper); break;
case "kg-gallery": buildGalleryEditUI(attrs, editPanel, wrapper); break;
case "kg-html": buildHtmlEditUI(attrs, editPanel, wrapper); break;
case "kg-md": buildMarkdownEditUI(attrs, editPanel, wrapper); break;
case "kg-embed": buildEmbedEditUI(attrs, editPanel, wrapper); break;
case "kg-bookmark": buildBookmarkEditUI(attrs, editPanel, wrapper); break;
case "kg-callout": buildCalloutEditUI(attrs, editPanel, wrapper); break;
case "kg-toggle": buildToggleEditUI(attrs, editPanel, wrapper); break;
case "kg-button": buildButtonEditUI(attrs, editPanel, wrapper); break;
case "kg-audio": buildAudioEditUI(attrs, editPanel, wrapper); break;
case "kg-video": buildVideoEditUI(attrs, editPanel, wrapper); break;
case "kg-file": buildFileEditUI(attrs, editPanel, wrapper); break;
default: buildGenericEditUI(attrs, editPanel, wrapper); break;
}
}
// -- Image card edit UI --
function buildImageEditUI(attrs, panel, wrapper) {
if (attrs.src) {
// Has image — show it with controls
var img = el("img", { src: attrs.src, className: "sx-edit-img-preview" });
panel.appendChild(img);
var controls = el("div", { className: "sx-edit-controls" });
var altRow = makeInputRow("Alt text", attrs.alt || "", function (v) {
attrs.alt = v; updateCardAttrs(wrapper, attrs);
});
controls.appendChild(altRow);
var widthRow = makeSelectRow("Width", attrs.width || "", [
{ value: "", label: "Normal" },
{ value: "wide", label: "Wide" },
{ value: "full", label: "Full" },
], function (v) {
attrs.width = v; updateCardAttrs(wrapper, attrs);
});
controls.appendChild(widthRow);
var hrefRow = makeInputRow("Link URL", attrs.href || "", function (v) {
attrs.href = v; updateCardAttrs(wrapper, attrs);
});
controls.appendChild(hrefRow);
var replaceBtn = el("button", {
type: "button", className: "sx-edit-btn sx-edit-btn-sm"
}, "Replace image");
replaceBtn.addEventListener("click", function () {
triggerFileUpload(wrapper, "image", function (url) {
attrs.src = url;
updateCardAttrs(wrapper, attrs);
img.src = url;
});
});
controls.appendChild(replaceBtn);
panel.appendChild(controls);
} else {
// No image yet — show upload area
buildUploadArea(panel, wrapper, "image", "Drop image here or click to upload", function (url) {
attrs.src = url;
updateCardAttrs(wrapper, attrs);
panel.innerHTML = "";
buildImageEditUI(attrs, panel, wrapper);
});
}
}
// -- Gallery card edit UI --
function buildGalleryEditUI(attrs, panel, wrapper) {
var images = attrs.images || [];
panel.innerHTML = "";
if (images.length) {
var grid = el("div", { className: "sx-edit-gallery-grid" });
// We store images as a stringified JSON in the `images` attr for now
var imgList;
try { imgList = typeof images === "string" ? JSON.parse(images) : images; } catch (e) { imgList = []; }
for (var i = 0; i < imgList.length; i++) {
(function (idx) {
var thumb = el("div", { className: "sx-edit-gallery-thumb" });
var img = el("img", { src: imgList[idx].src || imgList[idx] });
var removeBtn = el("button", {
type: "button", className: "sx-edit-gallery-remove", title: "Remove"
});
removeBtn.innerHTML = "&times;";
removeBtn.addEventListener("click", function () {
imgList.splice(idx, 1);
attrs.images = imgList;
updateCardAttrs(wrapper, attrs);
buildGalleryEditUI(attrs, panel, wrapper);
});
thumb.appendChild(img);
thumb.appendChild(removeBtn);
grid.appendChild(thumb);
})(i);
}
panel.appendChild(grid);
}
var addBtn = el("button", {
type: "button", className: "sx-edit-btn"
}, "+ Add images");
addBtn.addEventListener("click", function () {
triggerFileUpload(wrapper, "image", function (url) {
var imgList;
try { imgList = typeof attrs.images === "string" ? JSON.parse(attrs.images) : (attrs.images || []); } catch (e) { imgList = []; }
imgList.push({ src: url, alt: "" });
attrs.images = imgList;
updateCardAttrs(wrapper, attrs);
buildGalleryEditUI(attrs, panel, wrapper);
}, true);
});
panel.appendChild(addBtn);
}
// -- HTML card edit UI --
function buildHtmlEditUI(attrs, panel, wrapper) {
// Show HTML in textarea; convert HTML↔sx children for storage
var currentHtml = attrs._childrenSx ? sxChildrenToHtml(attrs._childrenSx) : "";
var textarea = el("textarea", {
className: "sx-edit-html-textarea",
placeholder: "Paste HTML here...",
spellcheck: "false"
}, currentHtml);
function autoResize() {
textarea.style.height = "auto";
textarea.style.height = Math.max(120, textarea.scrollHeight) + "px";
}
textarea.addEventListener("input", function () {
attrs._childrenSx = htmlToSx(textarea.value);
updateCardAttrs(wrapper, attrs);
autoResize();
});
setTimeout(autoResize, 0);
panel.appendChild(textarea);
}
// -- Markdown card edit UI (read-only rendered view, edit as HTML) --
function buildMarkdownEditUI(attrs, panel, wrapper) {
var currentHtml = attrs._childrenSx ? sxChildrenToHtml(attrs._childrenSx) : "";
var textarea = el("textarea", {
className: "sx-edit-html-textarea",
placeholder: "Markdown content (as HTML)...",
spellcheck: "false"
}, currentHtml);
function autoResize() {
textarea.style.height = "auto";
textarea.style.height = Math.max(120, textarea.scrollHeight) + "px";
}
textarea.addEventListener("input", function () {
attrs._childrenSx = htmlToSx(textarea.value);
updateCardAttrs(wrapper, attrs);
autoResize();
});
setTimeout(autoResize, 0);
panel.appendChild(textarea);
}
// -- Embed card edit UI --
function buildEmbedEditUI(attrs, panel, wrapper) {
if (attrs.html) {
// Already have embed HTML
var previewDiv = el("div", { className: "sx-edit-embed-preview" });
previewDiv.innerHTML = attrs.html;
panel.appendChild(previewDiv);
if (attrs.url) {
var urlDisplay = el("div", { className: "sx-edit-url-display" }, attrs.url);
panel.appendChild(urlDisplay);
}
var reloadBtn = el("button", {
type: "button", className: "sx-edit-btn sx-edit-btn-sm"
}, "Change URL");
reloadBtn.addEventListener("click", function () {
attrs.html = "";
attrs.url = "";
updateCardAttrs(wrapper, attrs);
panel.innerHTML = "";
buildEmbedEditUI(attrs, panel, wrapper);
});
panel.appendChild(reloadBtn);
} else {
// URL input for oEmbed lookup
var urlInput = el("input", {
type: "url",
className: "sx-edit-input sx-edit-url-input",
placeholder: "Paste URL to embed...",
value: attrs.url || ""
});
var status = el("div", { className: "sx-edit-status" });
urlInput.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
e.preventDefault();
fetchOembed(wrapper, urlInput.value, attrs, panel, status);
}
});
var fetchBtn = el("button", {
type: "button", className: "sx-edit-btn"
}, "Embed");
fetchBtn.addEventListener("click", function () {
fetchOembed(wrapper, urlInput.value, attrs, panel, status);
});
panel.appendChild(urlInput);
panel.appendChild(fetchBtn);
panel.appendChild(status);
}
}
// -- Bookmark card edit UI --
function buildBookmarkEditUI(attrs, panel, wrapper) {
if (attrs.url && attrs.title) {
// Already fetched — show editable fields
var controls = el("div", { className: "sx-edit-controls" });
controls.appendChild(makeInputRow("URL", attrs.url || "", function (v) {
attrs.url = v; updateCardAttrs(wrapper, attrs);
}));
controls.appendChild(makeInputRow("Title", attrs.title || "", function (v) {
attrs.title = v; updateCardAttrs(wrapper, attrs);
}));
controls.appendChild(makeInputRow("Description", attrs.description || "", function (v) {
attrs.description = v; updateCardAttrs(wrapper, attrs);
}));
controls.appendChild(makeInputRow("Author", attrs.author || "", function (v) {
attrs.author = v; updateCardAttrs(wrapper, attrs);
}));
controls.appendChild(makeInputRow("Publisher", attrs.publisher || "", function (v) {
attrs.publisher = v; updateCardAttrs(wrapper, attrs);
}));
controls.appendChild(makeInputRow("Thumbnail URL", attrs.thumbnail || "", function (v) {
attrs.thumbnail = v; updateCardAttrs(wrapper, attrs);
}));
controls.appendChild(makeInputRow("Icon URL", attrs.icon || "", function (v) {
attrs.icon = v; updateCardAttrs(wrapper, attrs);
}));
var refetchBtn = el("button", {
type: "button", className: "sx-edit-btn sx-edit-btn-sm"
}, "Re-fetch metadata");
refetchBtn.addEventListener("click", function () {
attrs.title = ""; attrs.description = ""; attrs.author = ""; attrs.publisher = "";
attrs.thumbnail = ""; attrs.icon = "";
updateCardAttrs(wrapper, attrs);
panel.innerHTML = "";
buildBookmarkEditUI(attrs, panel, wrapper);
});
controls.appendChild(refetchBtn);
panel.appendChild(controls);
} else {
// URL input for metadata fetch
var urlInput = el("input", {
type: "url",
className: "sx-edit-input sx-edit-url-input",
placeholder: "Paste URL for bookmark...",
value: attrs.url || ""
});
var status = el("div", { className: "sx-edit-status" });
urlInput.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
e.preventDefault();
fetchBookmark(wrapper, urlInput.value, attrs, panel, status);
}
});
var fetchBtn = el("button", {
type: "button", className: "sx-edit-btn"
}, "Add bookmark");
fetchBtn.addEventListener("click", function () {
fetchBookmark(wrapper, urlInput.value, attrs, panel, status);
});
panel.appendChild(urlInput);
panel.appendChild(fetchBtn);
panel.appendChild(status);
}
}
// -- Callout card edit UI --
function buildCalloutEditUI(attrs, panel, wrapper) {
var colors = ["grey", "white", "blue", "green", "yellow", "red", "pink", "purple"];
var colorMap = {
grey: "#f1f1f1", white: "#ffffff", blue: "#e8f0fe", green: "#e6f4ea",
yellow: "#fef7cd", red: "#fce8e6", pink: "#fce4ec", purple: "#f3e8fd"
};
var colorRow = el("div", { className: "sx-edit-color-row" });
for (var i = 0; i < colors.length; i++) {
(function (color) {
var swatch = el("button", {
type: "button",
className: "sx-edit-color-swatch" + (attrs.color === color ? " active" : ""),
style: "background:" + colorMap[color],
title: color
});
swatch.addEventListener("click", function () {
attrs.color = color;
updateCardAttrs(wrapper, attrs);
// Update active state
var swatches = colorRow.querySelectorAll(".sx-edit-color-swatch");
for (var j = 0; j < swatches.length; j++) swatches[j].classList.remove("active");
swatch.classList.add("active");
});
colorRow.appendChild(swatch);
})(colors[i]);
}
panel.appendChild(colorRow);
var emojiInput = el("input", {
type: "text",
className: "sx-edit-input sx-edit-emoji-input",
placeholder: "Emoji",
value: attrs.emoji || "",
maxlength: "4"
});
emojiInput.addEventListener("input", function () {
attrs.emoji = emojiInput.value;
updateCardAttrs(wrapper, attrs);
});
panel.appendChild(emojiInput);
var contentArea = el("div", {
contenteditable: "true",
className: "sx-editable sx-edit-callout-content",
"data-placeholder": "Callout text..."
});
contentArea.textContent = attrs.content || "";
contentArea.addEventListener("input", function () {
attrs.content = contentArea.textContent;
updateCardAttrs(wrapper, attrs);
});
panel.appendChild(contentArea);
}
// -- Toggle card edit UI --
function buildToggleEditUI(attrs, panel, wrapper) {
var headingInput = el("input", {
type: "text",
className: "sx-edit-input",
placeholder: "Toggle heading...",
value: attrs.heading || ""
});
headingInput.addEventListener("input", function () {
attrs.heading = headingInput.value;
updateCardAttrs(wrapper, attrs);
});
panel.appendChild(el("label", { className: "sx-edit-label" }, "Heading"));
panel.appendChild(headingInput);
var contentArea = el("div", {
contenteditable: "true",
className: "sx-editable sx-edit-toggle-content",
"data-placeholder": "Toggle content..."
});
contentArea.textContent = attrs.content || "";
contentArea.addEventListener("input", function () {
attrs.content = contentArea.textContent;
updateCardAttrs(wrapper, attrs);
});
panel.appendChild(el("label", { className: "sx-edit-label" }, "Content"));
panel.appendChild(contentArea);
}
// -- Button card edit UI --
function buildButtonEditUI(attrs, panel, wrapper) {
var controls = el("div", { className: "sx-edit-controls" });
controls.appendChild(makeInputRow("Button text", attrs.text || "", function (v) {
attrs.text = v; updateCardAttrs(wrapper, attrs);
}));
controls.appendChild(makeInputRow("Button URL", attrs.url || "", function (v) {
attrs.url = v; updateCardAttrs(wrapper, attrs);
}));
var alignRow = makeSelectRow("Alignment", attrs.alignment || "center", [
{ value: "left", label: "Left" },
{ value: "center", label: "Center" },
], function (v) {
attrs.alignment = v; updateCardAttrs(wrapper, attrs);
});
controls.appendChild(alignRow);
panel.appendChild(controls);
}
// -- Audio card edit UI --
function buildAudioEditUI(attrs, panel, wrapper) {
if (attrs.src) {
var audio = el("audio", { src: attrs.src, controls: "true", className: "sx-edit-audio-player" });
panel.appendChild(audio);
var controls = el("div", { className: "sx-edit-controls" });
controls.appendChild(makeInputRow("Title", attrs.title || "", function (v) {
attrs.title = v; updateCardAttrs(wrapper, attrs);
}));
var replaceBtn = el("button", {
type: "button", className: "sx-edit-btn sx-edit-btn-sm"
}, "Replace audio");
replaceBtn.addEventListener("click", function () {
triggerFileUpload(wrapper, "media", function (url) {
attrs.src = url;
updateCardAttrs(wrapper, attrs);
audio.src = url;
});
});
controls.appendChild(replaceBtn);
panel.appendChild(controls);
} else {
buildUploadArea(panel, wrapper, "media", "Drop audio file here or click to upload", function (url, file) {
attrs.src = url;
attrs.title = attrs.title || (file ? file.name.replace(/\.[^.]+$/, "") : "");
updateCardAttrs(wrapper, attrs);
panel.innerHTML = "";
buildAudioEditUI(attrs, panel, wrapper);
});
}
}
// -- Video card edit UI --
function buildVideoEditUI(attrs, panel, wrapper) {
if (attrs.src) {
var video = el("video", {
src: attrs.src, controls: "true", className: "sx-edit-video-player"
});
panel.appendChild(video);
var controls = el("div", { className: "sx-edit-controls" });
controls.appendChild(makeSelectRow("Width", attrs.width || "", [
{ value: "", label: "Normal" },
{ value: "wide", label: "Wide" },
{ value: "full", label: "Full" },
], function (v) {
attrs.width = v; updateCardAttrs(wrapper, attrs);
}));
var loopLabel = el("label", { className: "sx-edit-checkbox-label" });
var loopCheck = el("input", { type: "checkbox" });
loopCheck.checked = !!attrs.loop;
loopCheck.addEventListener("change", function () {
attrs.loop = loopCheck.checked;
updateCardAttrs(wrapper, attrs);
});
loopLabel.appendChild(loopCheck);
loopLabel.appendChild(document.createTextNode(" Loop"));
controls.appendChild(loopLabel);
var replaceBtn = el("button", {
type: "button", className: "sx-edit-btn sx-edit-btn-sm"
}, "Replace video");
replaceBtn.addEventListener("click", function () {
triggerFileUpload(wrapper, "media", function (url) {
attrs.src = url;
updateCardAttrs(wrapper, attrs);
video.src = url;
});
});
controls.appendChild(replaceBtn);
panel.appendChild(controls);
} else {
buildUploadArea(panel, wrapper, "media", "Drop video file here or click to upload", function (url) {
attrs.src = url;
updateCardAttrs(wrapper, attrs);
panel.innerHTML = "";
buildVideoEditUI(attrs, panel, wrapper);
});
}
}
// -- File card edit UI --
function buildFileEditUI(attrs, panel, wrapper) {
if (attrs.src) {
var controls = el("div", { className: "sx-edit-controls" });
controls.appendChild(makeInputRow("Filename", attrs.filename || "", function (v) {
attrs.filename = v; updateCardAttrs(wrapper, attrs);
}));
controls.appendChild(makeInputRow("Title", attrs.title || "", function (v) {
attrs.title = v; updateCardAttrs(wrapper, attrs);
}));
if (attrs.filesize) {
controls.appendChild(el("div", { className: "sx-edit-info" }, "Size: " + attrs.filesize));
}
var replaceBtn = el("button", {
type: "button", className: "sx-edit-btn sx-edit-btn-sm"
}, "Replace file");
replaceBtn.addEventListener("click", function () {
triggerFileUpload(wrapper, "file", function (url, file) {
attrs.src = url;
if (file) {
attrs.filename = file.name;
attrs.title = attrs.title || file.name;
attrs.filesize = formatFileSize(file.size);
}
updateCardAttrs(wrapper, attrs);
panel.innerHTML = "";
buildFileEditUI(attrs, panel, wrapper);
});
});
controls.appendChild(replaceBtn);
panel.appendChild(controls);
} else {
buildUploadArea(panel, wrapper, "file", "Drop file here or click to upload", function (url, file) {
attrs.src = url;
if (file) {
attrs.filename = file.name;
attrs.title = file.name;
attrs.filesize = formatFileSize(file.size);
}
updateCardAttrs(wrapper, attrs);
panel.innerHTML = "";
buildFileEditUI(attrs, panel, wrapper);
});
}
}
// -- Generic card edit UI (fallback) --
function buildGenericEditUI(attrs, panel, wrapper) {
var controls = el("div", { className: "sx-edit-controls" });
for (var k in attrs) {
(function (key) {
controls.appendChild(makeInputRow(key, String(attrs[key] || ""), function (v) {
attrs[key] = v; updateCardAttrs(wrapper, attrs);
}));
})(k);
}
panel.appendChild(controls);
}
// =========================================================================
// Shared edit UI helpers
// =========================================================================
function makeInputRow(label, value, onChange) {
var row = el("div", { className: "sx-edit-row" });
var lbl = el("label", { className: "sx-edit-label" }, label);
var input = el("input", {
type: "text", className: "sx-edit-input", value: value
});
input.addEventListener("input", function () { onChange(input.value); });
row.appendChild(lbl);
row.appendChild(input);
return row;
}
function makeSelectRow(label, value, options, onChange) {
var row = el("div", { className: "sx-edit-row" });
var lbl = el("label", { className: "sx-edit-label" }, label);
var select = el("select", { className: "sx-edit-select" });
for (var i = 0; i < options.length; i++) {
var opt = el("option", { value: options[i].value }, options[i].label);
if (options[i].value === value) opt.selected = true;
select.appendChild(opt);
}
select.addEventListener("change", function () { onChange(select.value); });
row.appendChild(lbl);
row.appendChild(select);
return row;
}
function buildUploadArea(panel, wrapper, uploadType, message, onUploaded) {
var area = el("div", { className: "sx-upload-area" });
var icon = el("div", { className: "sx-upload-icon" });
icon.innerHTML = '<i class="fa-solid fa-cloud-arrow-up"></i>';
var msg = el("div", { className: "sx-upload-msg" }, message);
var progress = el("div", { className: "sx-upload-progress", style: "display:none" });
area.appendChild(icon);
area.appendChild(msg);
area.appendChild(progress);
// Click to upload
area.addEventListener("click", function () {
triggerFileUpload(wrapper, uploadType, onUploaded);
});
// Drag and drop
area.addEventListener("dragover", function (e) {
e.preventDefault();
area.classList.add("sx-upload-dragover");
});
area.addEventListener("dragleave", function () {
area.classList.remove("sx-upload-dragover");
});
area.addEventListener("drop", function (e) {
e.preventDefault();
area.classList.remove("sx-upload-dragover");
var files = e.dataTransfer.files;
if (files.length) {
progress.style.display = "block";
progress.textContent = "Uploading...";
doUpload(wrapper, uploadType, files[0], function (url) {
onUploaded(url, files[0]);
}, function (err) {
progress.textContent = "Error: " + err;
setTimeout(function () { progress.style.display = "none"; }, 3000);
});
}
});
panel.appendChild(area);
}
// =========================================================================
// File upload
// =========================================================================
function triggerFileUpload(wrapper, uploadType, onUploaded, multi) {
var input = document.createElement("input");
input.type = "file";
if (multi) input.multiple = true;
if (uploadType === "image") input.accept = "image/jpeg,image/png,image/gif,image/webp,image/svg+xml";
else if (uploadType === "media") input.accept = "audio/*,video/*";
input.addEventListener("change", function () {
if (!input.files || !input.files.length) return;
for (var i = 0; i < input.files.length; i++) {
(function (file) {
doUpload(wrapper, uploadType, file, function (url) {
onUploaded(url, file);
}, function (err) {
console.error("Upload error:", err);
});
})(input.files[i]);
}
});
input.click();
}
function doUpload(wrapper, uploadType, file, onSuccess, onError) {
// Find the editor instance
var editor = findEditor(wrapper);
if (!editor || !editor._opts.uploadUrls) {
if (onError) onError("Upload not configured");
return;
}
var urlMap = { image: "image", media: "media", file: "file" };
var url = editor._opts.uploadUrls[urlMap[uploadType] || uploadType];
if (!url) {
if (onError) onError("Upload URL not configured for " + uploadType);
return;
}
var fd = new FormData();
fd.append("file", file);
fetch(url, {
method: "POST",
body: fd,
headers: { "X-CSRFToken": editor._opts.csrfToken || "" }
})
.then(function (r) {
if (!r.ok) throw new Error("Upload failed (" + r.status + ")");
return r.json();
})
.then(function (data) {
// Ghost returns { images: [{url}] } or { media: [{url}] } or { files: [{url}] }
var resultUrl = null;
if (data.images && data.images[0]) resultUrl = data.images[0].url;
else if (data.media && data.media[0]) resultUrl = data.media[0].url;
else if (data.files && data.files[0]) resultUrl = data.files[0].url;
else if (data.url) resultUrl = data.url;
if (!resultUrl) throw new Error("No URL in upload response");
onSuccess(resultUrl);
fireChange(editor);
})
.catch(function (e) {
if (onError) onError(e.message);
});
}
function findEditor(node) {
while (node) {
if (node._sxEditor) return node._sxEditor;
if (node.classList && node.classList.contains("sx-editor")) return node._sxEditor;
node = node.parentNode;
}
return null;
}
// =========================================================================
// oEmbed / bookmark metadata fetching
// =========================================================================
function fetchOembed(wrapper, url, attrs, panel, status) {
if (!url) { status.textContent = "Please enter a URL"; return; }
var editor = findEditor(wrapper);
if (!editor || !editor._opts.oembedUrl) {
status.textContent = "oEmbed not configured";
return;
}
status.textContent = "Fetching embed...";
fetch(editor._opts.oembedUrl + "?url=" + encodeURIComponent(url) + "&type=embed")
.then(function (r) {
if (!r.ok) throw new Error("oEmbed lookup failed (" + r.status + ")");
return r.json();
})
.then(function (data) {
if (data.html) {
attrs.html = data.html;
attrs.url = url;
if (data.title) attrs.title = data.title;
updateCardAttrs(wrapper, attrs);
panel.innerHTML = "";
buildEmbedEditUI(attrs, panel, wrapper);
fireChange(editor);
} else {
status.textContent = "No embed HTML returned. Try bookmark instead.";
}
})
.catch(function (e) {
status.textContent = "Error: " + e.message;
});
}
function fetchBookmark(wrapper, url, attrs, panel, status) {
if (!url) { status.textContent = "Please enter a URL"; return; }
var editor = findEditor(wrapper);
if (!editor || !editor._opts.oembedUrl) {
status.textContent = "oEmbed not configured";
return;
}
status.textContent = "Fetching metadata...";
fetch(editor._opts.oembedUrl + "?url=" + encodeURIComponent(url) + "&type=bookmark")
.then(function (r) {
if (!r.ok) throw new Error("Metadata fetch failed (" + r.status + ")");
return r.json();
})
.then(function (data) {
attrs.url = url;
attrs.title = data.metadata && data.metadata.title || data.title || "";
attrs.description = data.metadata && data.metadata.description || data.description || "";
attrs.author = data.metadata && data.metadata.author || data.author || "";
attrs.publisher = data.metadata && data.metadata.publisher || data.publisher || "";
attrs.thumbnail = data.metadata && data.metadata.thumbnail || data.thumbnail || "";
attrs.icon = data.metadata && data.metadata.icon || data.icon || "";
updateCardAttrs(wrapper, attrs);
panel.innerHTML = "";
buildBookmarkEditUI(attrs, panel, wrapper);
fireChange(editor);
})
.catch(function (e) {
status.textContent = "Error: " + e.message;
});
}
// =========================================================================
// Plus button (Koenig-style: single floating + on empty paragraphs)
// =========================================================================
function createPlusButton(editor) {
var plusContainer = el("div", { className: "sx-plus-container" });
plusContainer.style.display = "none";
var plusBtn = el("button", {
type: "button", className: "sx-plus-btn", title: "Add a card"
});
plusBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M7 0v14M0 7h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
plusContainer.appendChild(plusBtn);
editor._root.appendChild(plusContainer);
plusBtn.addEventListener("click", function (e) {
e.stopPropagation();
if (editor._plusMenuOpen) {
closePlusMenu(editor);
} else {
showPlusMenu(editor, plusContainer);
}
});
return plusContainer;
}
function positionPlusButton(editor) {
var plus = editor._plusContainer;
var block = editor._activeBlock;
if (!block) {
plus.style.display = "none";
return;
}
var editable = block.querySelector("[contenteditable]");
var isEmpty = editable && editable.textContent.trim() === "" && !editable.querySelector("img");
var tag = block.getAttribute("data-sx-tag");
var isTextBlock = TEXT_TAGS[tag];
if (!isTextBlock || !isEmpty) {
plus.style.display = "none";
return;
}
var containerRect = editor._root.getBoundingClientRect();
var blockRect = block.getBoundingClientRect();
plus.style.display = "flex";
plus.style.top = (blockRect.top - containerRect.top + (blockRect.height / 2) - 14) + "px";
plus.style.left = "-40px";
}
function showPlusMenu(editor, plusContainer) {
closePlusMenu(editor);
var menu = el("div", { className: "sx-plus-menu" });
for (var s = 0; s < CARD_MENU.length; s++) {
var section = CARD_MENU[s];
var sectionEl = el("div", { className: "sx-plus-menu-section" });
sectionEl.appendChild(el("div", { className: "sx-plus-menu-heading" }, section.section));
for (var j = 0; j < section.items.length; j++) {
(function (item) {
var row = el("button", {
type: "button", className: "sx-plus-menu-item"
});
row.innerHTML = '<span class="sx-plus-menu-icon"><i class="' + item.icon + '"></i></span>' +
'<span class="sx-plus-menu-label">' + escHtml(item.label) + '</span>' +
'<span class="sx-plus-menu-desc">' + escHtml(item.desc) + '</span>';
row.addEventListener("click", function (e) {
e.stopPropagation();
closePlusMenu(editor);
insertBlock(editor, item.type);
});
sectionEl.appendChild(row);
})(section.items[j]);
}
menu.appendChild(sectionEl);
}
// Text block types at the bottom
var textSection = el("div", { className: "sx-plus-menu-section" });
textSection.appendChild(el("div", { className: "sx-plus-menu-heading" }, "Text"));
var textItems = [
{ type: "p", icon: "fa-solid fa-paragraph", label: "Paragraph" },
{ type: "h2", icon: "fa-solid fa-heading", label: "Heading 2" },
{ type: "h3", icon: "fa-solid fa-heading", label: "Heading 3" },
{ type: "blockquote", icon: "fa-solid fa-quote-left",label: "Quote" },
{ type: "ul", icon: "fa-solid fa-list-ul", label: "Bulleted List" },
{ type: "ol", icon: "fa-solid fa-list-ol", label: "Numbered List" },
];
for (var t = 0; t < textItems.length; t++) {
(function (item) {
var row = el("button", {
type: "button", className: "sx-plus-menu-item"
});
row.innerHTML = '<span class="sx-plus-menu-icon"><i class="' + item.icon + '"></i></span>' +
'<span class="sx-plus-menu-label">' + escHtml(item.label) + '</span>';
row.addEventListener("click", function (e) {
e.stopPropagation();
closePlusMenu(editor);
convertBlock(editor, item.type);
});
textSection.appendChild(row);
})(textItems[t]);
}
menu.appendChild(textSection);
plusContainer.appendChild(menu);
editor._plusMenuOpen = true;
// Rotate the + to X
plusContainer.querySelector(".sx-plus-btn").classList.add("sx-plus-btn-open");
setTimeout(function () {
document.addEventListener("click", editor._closePlusHandler = function () {
closePlusMenu(editor);
}, { once: true });
}, 0);
}
function closePlusMenu(editor) {
if (editor._plusMenuOpen) {
var menu = editor._plusContainer.querySelector(".sx-plus-menu");
if (menu) menu.remove();
editor._plusMenuOpen = false;
var btn = editor._plusContainer.querySelector(".sx-plus-btn");
if (btn) btn.classList.remove("sx-plus-btn-open");
}
}
// =========================================================================
// Slash commands
// =========================================================================
function handleSlashCommand(editor, e) {
var editable = e.target;
if (!editable.hasAttribute || !editable.hasAttribute("contenteditable")) return;
var block = closestBlock(editable, editor._container);
if (!block) return;
var text = editable.textContent;
// If text starts with "/" show slash menu
if (text.charAt(0) === "/") {
var query = text.slice(1).toLowerCase();
showSlashMenu(editor, block, editable, query);
} else {
closeSlashMenu(editor);
}
}
function showSlashMenu(editor, block, editable, query) {
closeSlashMenu(editor);
// Filter matching items
var matches = [];
for (var i = 0; i < ALL_CARD_ITEMS.length; i++) {
var item = ALL_CARD_ITEMS[i];
if (!query) {
matches.push(item);
} else {
// Match against label and slash commands
var matchFound = item.label.toLowerCase().indexOf(query) !== -1;
if (!matchFound && item.slash) {
for (var s = 0; s < item.slash.length; s++) {
if (item.slash[s].indexOf(query) !== -1) { matchFound = true; break; }
}
}
if (matchFound) matches.push(item);
}
}
if (matches.length === 0) return;
var menu = el("div", { className: "sx-slash-menu" });
for (var i = 0; i < matches.length && i < 8; i++) {
(function (item) {
var row = el("button", {
type: "button", className: "sx-slash-menu-item"
});
row.innerHTML = '<span class="sx-slash-menu-icon"><i class="' + item.icon + '"></i></span>' +
'<span class="sx-slash-menu-label">' + escHtml(item.label) + '</span>' +
'<span class="sx-slash-menu-desc">' + escHtml(item.desc) + '</span>';
row.addEventListener("mousedown", function (e) {
e.preventDefault();
e.stopPropagation();
closeSlashMenu(editor);
// Clear the slash text
editable.textContent = "";
insertBlock(editor, item.type);
});
menu.appendChild(row);
})(matches[i]);
}
// Position below the block
var blockRect = block.getBoundingClientRect();
var containerRect = editor._root.getBoundingClientRect();
menu.style.top = (blockRect.bottom - containerRect.top + 4) + "px";
menu.style.left = "0px";
editor._root.appendChild(menu);
editor._slashMenu = menu;
editor._slashBlock = block;
}
function closeSlashMenu(editor) {
if (editor._slashMenu) {
editor._slashMenu.remove();
editor._slashMenu = null;
editor._slashBlock = null;
}
}
// =========================================================================
// Block insertion & conversion
// =========================================================================
function insertBlock(editor, type) {
var block;
var container = editor._container;
var refBlock = editor._activeBlock;
if (TEXT_TAGS[type]) {
block = createTextBlock(type, "");
} else if (LIST_TAGS[type]) {
block = createListBlock(type, []);
} else if (type === "hr") {
block = createHrBlock();
} else if (type === "code") {
block = createCodeBlock("", "");
} else if (type === "image") {
// Create empty image card — edit mode opens automatically
block = createCardBlock("kg-image", {});
insertBlockNode(editor, block, refBlock);
// Open edit mode immediately
block.querySelector(".sx-card-preview").click();
return;
} else if (type === "gallery") {
block = createCardBlock("kg-gallery", {});
insertBlockNode(editor, block, refBlock);
block.querySelector(".sx-card-preview").click();
return;
} else if (type === "video") {
block = createCardBlock("kg-video", {});
insertBlockNode(editor, block, refBlock);
block.querySelector(".sx-card-preview").click();
return;
} else if (type === "audio") {
block = createCardBlock("kg-audio", {});
insertBlockNode(editor, block, refBlock);
block.querySelector(".sx-card-preview").click();
return;
} else if (type === "file") {
block = createCardBlock("kg-file", {});
insertBlockNode(editor, block, refBlock);
block.querySelector(".sx-card-preview").click();
return;
} else if (type === "callout") {
block = createCardBlock("kg-callout", { color: "grey", emoji: "", content: "" });
insertBlockNode(editor, block, refBlock);
block.querySelector(".sx-card-preview").click();
return;
} else if (type === "toggle") {
block = createCardBlock("kg-toggle", { heading: "", content: "" });
insertBlockNode(editor, block, refBlock);
block.querySelector(".sx-card-preview").click();
return;
} else if (type === "html") {
block = createCardBlock("kg-html", { _childrenSx: '""' });
insertBlockNode(editor, block, refBlock);
block.querySelector(".sx-card-preview").click();
return;
} else if (type === "button") {
block = createCardBlock("kg-button", { url: "", text: "Click here", alignment: "center" });
insertBlockNode(editor, block, refBlock);
block.querySelector(".sx-card-preview").click();
return;
} else if (type === "bookmark") {
block = createCardBlock("kg-bookmark", {});
insertBlockNode(editor, block, refBlock);
block.querySelector(".sx-card-preview").click();
return;
} else if (type === "embed") {
block = createCardBlock("kg-embed", {});
insertBlockNode(editor, block, refBlock);
block.querySelector(".sx-card-preview").click();
return;
}
if (!block) return;
insertBlockNode(editor, block, refBlock);
var editable = block.querySelector("[contenteditable]");
if (editable) editable.focus();
else {
var ta = block.querySelector("textarea");
if (ta) ta.focus();
}
}
function insertBlockNode(editor, block, refBlock) {
var container = editor._container;
if (refBlock && refBlock.parentNode === container) {
refBlock.parentNode.insertBefore(block, refBlock.nextSibling);
} else {
container.appendChild(block);
}
editor._activeBlock = block;
fireChange(editor);
}
function convertBlock(editor, newType) {
var block = editor._activeBlock;
if (!block) return;
var tag = block.getAttribute("data-sx-tag");
// If same type, do nothing
if (tag === newType) return;
// For text blocks, change the tag
if (TEXT_TAGS[tag] && TEXT_TAGS[newType]) {
var editable = block.querySelector("[contenteditable]");
var html = editable ? editable.innerHTML : "";
block.setAttribute("data-sx-tag", newType);
if (editable) {
editable.className = "sx-block-content sx-editable" +
(newType === "h2" || newType === "h3" ? " sx-heading" : "") +
(newType === "blockquote" ? " sx-quote" : "");
editable.setAttribute("data-placeholder",
newType === "p" ? "Type / for commands..." :
newType === "blockquote" ? "Type a quote..." :
"Type a heading...");
editable.setAttribute("data-block-type", newType);
editable.focus();
}
fireChange(editor);
return;
}
// For text→list conversion
if (TEXT_TAGS[tag] && LIST_TAGS[newType]) {
var editable = block.querySelector("[contenteditable]");
var html = editable ? editable.innerHTML : "";
var newBlock = createListBlock(newType, []);
var firstLi = newBlock.querySelector("[data-sx-li]");
if (firstLi) firstLi.innerHTML = html;
block.parentNode.replaceChild(newBlock, block);
editor._activeBlock = newBlock;
var li = newBlock.querySelector("[contenteditable]");
if (li) focusEnd(li);
fireChange(editor);
return;
}
// For list→text conversion
if (LIST_TAGS[tag] && TEXT_TAGS[newType]) {
var firstLi = block.querySelector("[data-sx-li]");
var html = firstLi ? firstLi.innerHTML : "";
var newBlock = createTextBlock(newType, html);
block.parentNode.replaceChild(newBlock, block);
editor._activeBlock = newBlock;
var ed = newBlock.querySelector("[contenteditable]");
if (ed) focusEnd(ed);
fireChange(editor);
return;
}
}
// =========================================================================
// Inline formatting toolbar
// =========================================================================
function createFormatBar() {
var bar = el("div", { className: "sx-format-bar" });
bar.style.display = "none";
var commands = [
{ cmd: "bold", label: "B", cls: "sx-fmt-bold", shortcut: "Ctrl+B" },
{ cmd: "italic", label: "I", cls: "sx-fmt-italic", shortcut: "Ctrl+I" },
{ cmd: "h2", label: "H2", cls: "", shortcut: "Ctrl+Alt+2" },
{ cmd: "h3", label: "H3", cls: "", shortcut: "Ctrl+Alt+3" },
{ cmd: "blockquote", label: "❝", cls: "", shortcut: "" },
{ cmd: "link", label: '<i class="fa-solid fa-link"></i>', cls: "", shortcut: "Ctrl+K" },
];
for (var i = 0; i < commands.length; i++) {
(function (c) {
var btn = el("button", {
type: "button",
className: "sx-format-btn" + (c.cls ? " " + c.cls : ""),
title: c.cmd + (c.shortcut ? " (" + c.shortcut + ")" : "")
});
btn.innerHTML = c.label;
btn.addEventListener("mousedown", function (e) {
e.preventDefault();
applyFormat(c.cmd);
});
bar.appendChild(btn);
})(commands[i]);
}
document.body.appendChild(bar);
return bar;
}
function applyFormat(cmd) {
if (cmd === "link") {
var sel = window.getSelection();
if (!sel.rangeCount) return;
// Check if already linked
var anchor = sel.anchorNode;
while (anchor && anchor.tagName !== "A") anchor = anchor.parentNode;
if (anchor && anchor.tagName === "A") {
document.execCommand("unlink", false, null);
} else {
var url = prompt("Link URL:");
if (url) document.execCommand("createLink", false, url);
}
} else if (cmd === "h2" || cmd === "h3" || cmd === "blockquote") {
// Block-level format change: find the current block and convert it
var sel = window.getSelection();
if (!sel.anchorNode) return;
var node = sel.anchorNode;
while (node && !(node.hasAttribute && node.hasAttribute("data-sx-block"))) {
node = node.parentNode;
}
if (!node) return;
var currentTag = node.getAttribute("data-sx-tag");
// Toggle: if already this type, revert to p
var newType = currentTag === cmd ? "p" : cmd;
var editable = node.querySelector("[contenteditable]");
if (editable) {
node.setAttribute("data-sx-tag", newType);
editable.className = "sx-block-content sx-editable" +
(newType === "h2" || newType === "h3" ? " sx-heading" : "") +
(newType === "blockquote" ? " sx-quote" : "");
editable.setAttribute("data-placeholder",
newType === "p" ? "Type / for commands..." :
newType === "blockquote" ? "Type a quote..." :
"Type a heading...");
editable.setAttribute("data-block-type", newType);
}
} else if (cmd === "code") {
var sel = window.getSelection();
if (sel.rangeCount) {
var range = sel.getRangeAt(0);
var code = document.createElement("code");
try {
range.surroundContents(code);
} catch (ex) {
document.execCommand("insertHTML", false, "<code>" + escHtml(sel.toString()) + "</code>");
}
}
} else {
document.execCommand(cmd, false, null);
}
}
function updateFormatBar(editor) {
var bar = editor._formatBar;
var sel = window.getSelection();
if (!sel.rangeCount || sel.isCollapsed) {
bar.style.display = "none";
return;
}
var anchor = sel.anchorNode;
var inEditor = false;
var node = anchor;
while (node) {
if (node === editor._container) { inEditor = true; break; }
node = node.parentNode;
}
if (!inEditor) {
bar.style.display = "none";
return;
}
var range = sel.getRangeAt(0);
var rect = range.getBoundingClientRect();
if (rect.width === 0) {
bar.style.display = "none";
return;
}
bar.style.display = "flex";
bar.style.top = (rect.top + window.scrollY - bar.offsetHeight - 8) + "px";
bar.style.left = (rect.left + window.scrollX + rect.width / 2 - bar.offsetWidth / 2) + "px";
}
// =========================================================================
// Keyboard handling
// =========================================================================
function handleKeydown(editor, e) {
var block = closestBlock(e.target, editor._container);
if (!block) return;
var tag = block.getAttribute("data-sx-tag");
// Keyboard shortcuts
var mod = e.metaKey || e.ctrlKey;
if (mod && !e.shiftKey && !e.altKey) {
if (e.key === "b" || e.key === "B") {
e.preventDefault(); applyFormat("bold"); return;
}
if (e.key === "i" || e.key === "I") {
e.preventDefault(); applyFormat("italic"); return;
}
if (e.key === "k" || e.key === "K") {
e.preventDefault(); applyFormat("link"); return;
}
}
if (mod && e.altKey) {
if (e.key === "2") { e.preventDefault(); applyFormat("h2"); return; }
if (e.key === "3") { e.preventDefault(); applyFormat("h3"); return; }
}
// Escape closes slash menu and card edit
if (e.key === "Escape") {
closeSlashMenu(editor);
closePlusMenu(editor);
// Exit card edit mode if in one
var editingCard = editor._container.querySelector(".sx-card-editing");
if (editingCard) exitCardEdit(editingCard);
return;
}
// Slash menu keyboard navigation
if (editor._slashMenu) {
if (e.key === "Escape") {
e.preventDefault();
closeSlashMenu(editor);
return;
}
}
// Enter in text block → new paragraph
if (e.key === "Enter" && !e.shiftKey) {
if (TEXT_TAGS[tag]) {
e.preventDefault();
// Split at cursor or insert new p after
insertBlock(editor, "p");
return;
}
// Enter in list item → new list item or exit list
if (LIST_TAGS[tag]) {
var li = e.target;
if (li.hasAttribute("data-sx-li") && li.textContent.trim() === "") {
// Empty list item → exit list, create new paragraph
e.preventDefault();
if (block.querySelectorAll("[data-sx-li]").length <= 1) {
// Only one empty item → convert whole list to paragraph
var newBlock = createTextBlock("p", "");
block.parentNode.replaceChild(newBlock, block);
editor._activeBlock = newBlock;
var ed = newBlock.querySelector("[contenteditable]");
if (ed) ed.focus();
} else {
// Remove this item and add paragraph after list
li.remove();
var newBlock = createTextBlock("p", "");
block.parentNode.insertBefore(newBlock, block.nextSibling);
editor._activeBlock = newBlock;
var ed = newBlock.querySelector("[contenteditable]");
if (ed) ed.focus();
}
fireChange(editor);
return;
}
if (li.hasAttribute("data-sx-li")) {
e.preventDefault();
var newLi = el("div", {
contenteditable: "true",
className: "sx-list-item sx-editable",
"data-sx-li": "true",
"data-placeholder": "List item..."
});
li.parentNode.insertBefore(newLi, li.nextSibling);
newLi.focus();
fireChange(editor);
return;
}
}
}
// Backspace in empty text block → delete block
if (e.key === "Backspace") {
var editable = e.target;
if (editable.hasAttribute("contenteditable") &&
editable.textContent.trim() === "" &&
!editable.querySelector("img")) {
// In list, remove item
if (editable.hasAttribute("data-sx-li")) {
var items = block.querySelectorAll("[data-sx-li]");
if (items.length > 1) {
e.preventDefault();
var prevItem = editable.previousElementSibling;
editable.remove();
if (prevItem) focusEnd(prevItem);
fireChange(editor);
return;
}
}
var allBlocks = editor._container.querySelectorAll("[data-sx-block]");
if (allBlocks.length > 1) {
e.preventDefault();
removeBlock(editor, block);
return;
}
}
}
// Arrow up at start of block → focus previous block
if (e.key === "ArrowUp") {
var editable = e.target;
if (editable.hasAttribute("contenteditable")) {
var sel = window.getSelection();
if (sel.rangeCount) {
var range = sel.getRangeAt(0);
if (range.startOffset === 0 && range.collapsed) {
var prev = block.previousElementSibling;
while (prev && !prev.hasAttribute("data-sx-block")) prev = prev.previousElementSibling;
if (prev) {
e.preventDefault();
var prevEditable = prev.querySelector("[contenteditable]:last-child") ||
prev.querySelector("[contenteditable]");
if (prevEditable) focusEnd(prevEditable);
editor._activeBlock = prev;
positionPlusButton(editor);
}
}
}
}
}
// Arrow down at end of block → focus next block
if (e.key === "ArrowDown") {
var editable = e.target;
if (editable.hasAttribute("contenteditable")) {
var sel = window.getSelection();
if (sel.rangeCount) {
var range = sel.getRangeAt(0);
var atEnd = range.collapsed && range.startContainer.nodeType === 3 &&
range.startOffset === range.startContainer.textContent.length;
if (!atEnd) atEnd = range.collapsed && range.startOffset === editable.childNodes.length;
if (atEnd) {
var next = block.nextElementSibling;
while (next && !next.hasAttribute("data-sx-block")) next = next.nextElementSibling;
if (next) {
e.preventDefault();
var nextEditable = next.querySelector("[contenteditable]");
if (nextEditable) {
nextEditable.focus();
var r = document.createRange();
r.setStart(nextEditable, 0);
r.collapse(true);
sel.removeAllRanges();
sel.addRange(r);
}
editor._activeBlock = next;
positionPlusButton(editor);
}
}
}
}
}
}
function removeBlock(editor, block) {
var prev = block.previousElementSibling;
while (prev && !prev.hasAttribute("data-sx-block")) prev = prev.previousElementSibling;
block.remove();
if (prev) {
var editable = prev.querySelector("[contenteditable]:last-child") ||
prev.querySelector("[contenteditable]");
if (editable) focusEnd(editable);
editor._activeBlock = prev;
} else {
var first = editor._container.querySelector("[data-sx-block]");
if (first) {
var editable = first.querySelector("[contenteditable]");
if (editable) editable.focus();
editor._activeBlock = first;
}
}
positionPlusButton(editor);
fireChange(editor);
}
// =========================================================================
// Change handler
// =========================================================================
function fireChange(editor) {
if (editor._changeTimeout) clearTimeout(editor._changeTimeout);
editor._changeTimeout = setTimeout(function () {
var sx = serializeBlocks(editor._container);
if (editor._opts.onChange) editor._opts.onChange(sx);
}, 150);
}
// =========================================================================
// Mount
// =========================================================================
function mount(elementId, opts) {
opts = opts || {};
var root = document.getElementById(elementId);
if (!root) {
console.error("sx-editor: element not found: #" + elementId);
return null;
}
root.className = (root.className || "") + " sx-editor";
var container = el("div", { className: "sx-blocks-container" });
root.appendChild(container);
var editor = {
_root: root,
_container: container,
_opts: opts,
_formatBar: createFormatBar(),
_plusContainer: null,
_plusMenuOpen: false,
_closePlusHandler: null,
_slashMenu: null,
_slashBlock: null,
_activeBlock: null,
_changeTimeout: null
};
// Store editor reference on root for findEditor()
root._sxEditor = editor;
// Create floating + button
editor._plusContainer = createPlusButton(editor);
// Load initial content
var blocks = [];
if (opts.initialSx) {
blocks = deserializeSx(opts.initialSx);
}
if (blocks.length === 0) {
blocks = [createTextBlock("p", "")];
}
for (var i = 0; i < blocks.length; i++) {
container.appendChild(blocks[i]);
}
// Track active block
container.addEventListener("focusin", function (e) {
var block = closestBlock(e.target, container);
if (block) {
editor._activeBlock = block;
positionPlusButton(editor);
}
});
container.addEventListener("click", function (e) {
var block = closestBlock(e.target, container);
if (block) {
editor._activeBlock = block;
positionPlusButton(editor);
}
});
// Click on container background → focus last block or add new paragraph
container.addEventListener("click", function (e) {
if (e.target === container) {
// Clicked empty area below blocks → focus last block or create new one
var lastBlock = container.querySelector("[data-sx-block]:last-child");
if (lastBlock) {
var editable = lastBlock.querySelector("[contenteditable]");
if (editable && editable.textContent.trim() === "") {
editable.focus();
} else {
// Add a new paragraph at the end
var newBlock = createTextBlock("p", "");
container.appendChild(newBlock);
newBlock.querySelector("[contenteditable]").focus();
editor._activeBlock = newBlock;
positionPlusButton(editor);
fireChange(editor);
}
}
}
});
// Event delegation
container.addEventListener("input", function (e) {
handleSlashCommand(editor, e);
fireChange(editor);
});
container.addEventListener("keydown", function (e) {
handleKeydown(editor, e);
});
// Click outside card → exit edit mode
document.addEventListener("click", function (e) {
var editingCards = editor._container.querySelectorAll(".sx-card-editing");
for (var i = 0; i < editingCards.length; i++) {
if (!editingCards[i].contains(e.target)) {
exitCardEdit(editingCards[i]);
fireChange(editor);
}
}
});
// Delete card button
container.addEventListener("click", function (e) {
var deleteBtn = e.target.closest(".sx-card-tool-btn");
if (deleteBtn) {
var block = closestBlock(deleteBtn, container);
if (block) removeBlock(editor, block);
}
});
document.addEventListener("selectionchange", function () {
updateFormatBar(editor);
});
// Drag-drop on the whole editor
container.addEventListener("dragover", function (e) {
e.preventDefault();
container.classList.add("sx-drag-over");
});
container.addEventListener("dragleave", function (e) {
if (!container.contains(e.relatedTarget)) {
container.classList.remove("sx-drag-over");
}
});
container.addEventListener("drop", function (e) {
container.classList.remove("sx-drag-over");
var files = e.dataTransfer && e.dataTransfer.files;
if (!files || !files.length) return;
e.preventDefault();
// Find drop position
var dropBlock = closestBlock(e.target, container);
editor._activeBlock = dropBlock || container.querySelector("[data-sx-block]:last-child");
for (var i = 0; i < files.length; i++) {
(function (file) {
var type = file.type;
if (type.startsWith("image/")) {
var block = createCardBlock("kg-image", {});
insertBlockNode(editor, block, editor._activeBlock);
doUpload(block, "image", file, function (url) {
var attrs = { src: url, alt: "" };
updateCardAttrs(block, attrs);
block.setAttribute("data-sx-attrs", JSON.stringify(attrs));
renderCardPreview("kg-image", attrs, block.querySelector(".sx-card-preview"));
fireChange(editor);
}, function (err) {
console.error("Upload error:", err);
});
} else if (type.startsWith("video/")) {
var block = createCardBlock("kg-video", {});
insertBlockNode(editor, block, editor._activeBlock);
doUpload(block, "media", file, function (url) {
var attrs = { src: url };
updateCardAttrs(block, attrs);
block.setAttribute("data-sx-attrs", JSON.stringify(attrs));
renderCardPreview("kg-video", attrs, block.querySelector(".sx-card-preview"));
fireChange(editor);
}, function (err) {
console.error("Upload error:", err);
});
} else if (type.startsWith("audio/")) {
var block = createCardBlock("kg-audio", {});
insertBlockNode(editor, block, editor._activeBlock);
doUpload(block, "media", file, function (url) {
var attrs = { src: url, title: file.name.replace(/\.[^.]+$/, "") };
updateCardAttrs(block, attrs);
block.setAttribute("data-sx-attrs", JSON.stringify(attrs));
renderCardPreview("kg-audio", attrs, block.querySelector(".sx-card-preview"));
fireChange(editor);
}, function (err) {
console.error("Upload error:", err);
});
} else {
var block = createCardBlock("kg-file", {});
insertBlockNode(editor, block, editor._activeBlock);
doUpload(block, "file", file, function (url) {
var attrs = { src: url, filename: file.name, title: file.name, filesize: formatFileSize(file.size) };
updateCardAttrs(block, attrs);
block.setAttribute("data-sx-attrs", JSON.stringify(attrs));
renderCardPreview("kg-file", attrs, block.querySelector(".sx-card-preview"));
fireChange(editor);
}, function (err) {
console.error("Upload error:", err);
});
}
})(files[i]);
}
});
// Set min-height and cursor for clicking below content
container.style.minHeight = "300px";
container.style.cursor = "text";
// Focus first editable
var firstEditable = container.querySelector("[contenteditable]");
if (firstEditable) firstEditable.focus();
editor._activeBlock = container.querySelector("[data-sx-block]");
positionPlusButton(editor);
return {
getSx: function () {
return serializeBlocks(container);
},
destroy: function () {
if (editor._formatBar) editor._formatBar.remove();
if (editor._plusContainer) editor._plusContainer.remove();
root._sxEditor = null;
root.innerHTML = "";
root.className = root.className.replace(/\bsx-editor\b/, "").trim();
}
};
}
// =========================================================================
// Export
// =========================================================================
window.SxEditor = {
mount: mount,
_serializeBlocks: serializeBlocks,
_serializeInline: serializeInline,
_deserializeSx: deserializeSx
};
})();