/** * 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: "markdown", icon: "fa-brands fa-markdown", label: "Markdown",desc: "Insert markdown content", slash: ["markdown", "md"] }, { 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: "youtube", icon: "fa-brands fa-youtube", label: "YouTube", desc: "Embed a YouTube video", slash: ["youtube", "yt"] }, { type: "twitter", icon: "fa-brands fa-x-twitter", label: "X (Twitter)", desc: "Embed a tweet", slash: ["twitter", "tweet", "x"] }, { type: "vimeo", icon: "fa-brands fa-vimeo-v", label: "Vimeo", desc: "Embed a Vimeo video", slash: ["vimeo"] }, { type: "spotify", icon: "fa-brands fa-spotify", label: "Spotify", desc: "Embed a Spotify track or playlist", slash: ["spotify"] }, { type: "codepen", icon: "fa-brands fa-codepen", label: "CodePen", desc: "Embed a CodePen", slash: ["codepen"] }, { 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 any URL via oEmbed", slash: ["embed", "oembed"] }, { 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("
" + html + "", "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 += '' + renderInlineToHtml(rest) + ""; } 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 = ''; 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 = '" + escHtml(sel.toString()) + "");
}
}
} 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
// =========================================================================
var VERSION = "2026-03-02b-exorcism";
window.SxEditor = {
VERSION: VERSION,
mount: mount,
_serializeBlocks: serializeBlocks,
_serializeInline: serializeInline,
_deserializeSx: deserializeSx
};
console.log("[sx-editor] v" + VERSION + " init");
})();