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