/** * 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; } 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 ") + ")"; } 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 = {}; } var captionEl = block.querySelector("[data-sx-caption]"); if (captionEl) { var captionText = captionEl.textContent.trim(); if (captionText) attrs.caption = captionText; else delete attrs.caption; } var parts = ["(~" + cardType]; for (var k in attrs) { if (attrs[k] === null || attrs[k] === undefined || attrs[k] === false) continue; if (attrs[k] === true) { parts.push(":" + k + " true"); } else { parts.push(':' + k + ' "' + escSx(String(attrs[k])) + '"'); } } 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 attrs = extractKwargs(expr.slice(1)); 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)) + ""; } 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 }; 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) captionEl.textContent = 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(cardType) + ' card]
'; } } 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 (attrs[k] === true) { parts.push(":" + k + " true"); } else { parts.push(':' + k + ' "' + escSx(String(attrs[k])) + '"'); } } 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-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 = "×"; 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) { var textarea = el("textarea", { className: "sx-edit-html-textarea", placeholder: "Paste HTML here...", spellcheck: "false" }, attrs.html || ""); function autoResize() { textarea.style.height = "auto"; textarea.style.height = Math.max(120, textarea.scrollHeight) + "px"; } textarea.addEventListener("input", function () { attrs.html = 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 = ''; 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 = ''; 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 = '' + '' + escHtml(item.label) + '' + '' + escHtml(item.desc) + ''; 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 = '' + '' + escHtml(item.label) + ''; 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 = '' + '' + escHtml(item.label) + '' + '' + escHtml(item.desc) + ''; 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", { html: "" }); 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: '', 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, "" + 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 // ========================================================================= window.SxEditor = { mount: mount, _serializeBlocks: serializeBlocks, _serializeInline: serializeInline, _deserializeSx: deserializeSx }; })();