diff --git a/blog/alembic/versions/0005_add_sx_content.py b/blog/alembic/versions/0005_add_sx_content.py
new file mode 100644
index 0000000..0d99d92
--- /dev/null
+++ b/blog/alembic/versions/0005_add_sx_content.py
@@ -0,0 +1,20 @@
+"""Add sx_content column to posts table.
+
+Revision ID: blog_0005
+Revises: blog_0004
+"""
+from alembic import op
+import sqlalchemy as sa
+
+revision = "blog_0005"
+down_revision = "blog_0004"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.add_column("posts", sa.Column("sx_content", sa.Text(), nullable=True))
+
+
+def downgrade():
+ op.drop_column("posts", "sx_content")
diff --git a/blog/bp/blog/ghost/lexical_to_sx.py b/blog/bp/blog/ghost/lexical_to_sx.py
new file mode 100644
index 0000000..e29b8bf
--- /dev/null
+++ b/blog/bp/blog/ghost/lexical_to_sx.py
@@ -0,0 +1,430 @@
+"""
+Lexical JSON → s-expression converter.
+
+Mirrors lexical_renderer.py's registry/dispatch pattern but produces sx source
+instead of HTML. Used for backfilling existing posts and on-the-fly conversion
+when editing pre-migration posts in the SX editor.
+
+Public API
+----------
+ lexical_to_sx(doc) – Lexical JSON (dict or string) → sx source string
+"""
+from __future__ import annotations
+
+import json
+from typing import Callable
+
+
+# ---------------------------------------------------------------------------
+# Registry
+# ---------------------------------------------------------------------------
+
+_CONVERTERS: dict[str, Callable[[dict], str]] = {}
+
+
+def _converter(node_type: str):
+ """Decorator — register a function as the converter for *node_type*."""
+ def decorator(fn: Callable[[dict], str]) -> Callable[[dict], str]:
+ _CONVERTERS[node_type] = fn
+ return fn
+ return decorator
+
+
+# ---------------------------------------------------------------------------
+# Public entry point
+# ---------------------------------------------------------------------------
+
+def lexical_to_sx(doc: dict | str) -> str:
+ """Convert a Lexical JSON document to an sx source string."""
+ if isinstance(doc, str):
+ doc = json.loads(doc)
+ root = doc.get("root", doc)
+ children = root.get("children", [])
+ parts = [_convert_node(c) for c in children]
+ parts = [p for p in parts if p]
+ if not parts:
+ return '(<> (p ""))'
+ if len(parts) == 1:
+ return parts[0]
+ return "(<>\n " + "\n ".join(parts) + ")"
+
+
+# ---------------------------------------------------------------------------
+# Core dispatch
+# ---------------------------------------------------------------------------
+
+def _convert_node(node: dict) -> str:
+ node_type = node.get("type", "")
+ converter = _CONVERTERS.get(node_type)
+ if converter:
+ return converter(node)
+ return ""
+
+
+def _convert_children(children: list[dict]) -> str:
+ """Convert children to inline sx content (for text nodes)."""
+ parts = [_convert_node(c) for c in children]
+ return " ".join(p for p in parts if p)
+
+
+def _esc(s: str) -> str:
+ """Escape a string for sx double-quoted literals."""
+ return s.replace("\\", "\\\\").replace('"', '\\"')
+
+
+# ---------------------------------------------------------------------------
+# Text format bitmask
+# ---------------------------------------------------------------------------
+
+_FORMAT_BOLD = 1
+_FORMAT_ITALIC = 2
+_FORMAT_STRIKETHROUGH = 4
+_FORMAT_UNDERLINE = 8
+_FORMAT_CODE = 16
+_FORMAT_SUBSCRIPT = 32
+_FORMAT_SUPERSCRIPT = 64
+
+_FORMAT_WRAPPERS: list[tuple[int, str]] = [
+ (_FORMAT_BOLD, "strong"),
+ (_FORMAT_ITALIC, "em"),
+ (_FORMAT_STRIKETHROUGH, "s"),
+ (_FORMAT_UNDERLINE, "u"),
+ (_FORMAT_CODE, "code"),
+ (_FORMAT_SUBSCRIPT, "sub"),
+ (_FORMAT_SUPERSCRIPT, "sup"),
+]
+
+
+def _wrap_format(text_sx: str, fmt: int) -> str:
+ for mask, tag in _FORMAT_WRAPPERS:
+ if fmt & mask:
+ text_sx = f"({tag} {text_sx})"
+ return text_sx
+
+
+# ---------------------------------------------------------------------------
+# Tier 1 — text nodes
+# ---------------------------------------------------------------------------
+
+@_converter("text")
+def _text(node: dict) -> str:
+ text = node.get("text", "")
+ if not text:
+ return ""
+ sx = f'"{_esc(text)}"'
+ fmt = node.get("format", 0)
+ if isinstance(fmt, int) and fmt:
+ sx = _wrap_format(sx, fmt)
+ return sx
+
+
+@_converter("linebreak")
+def _linebreak(_node: dict) -> str:
+ return '"\\n"'
+
+
+@_converter("tab")
+def _tab(_node: dict) -> str:
+ return '"\\t"'
+
+
+@_converter("paragraph")
+def _paragraph(node: dict) -> str:
+ inner = _convert_children(node.get("children", []))
+ if not inner:
+ inner = '""'
+ return f"(p {inner})"
+
+
+@_converter("extended-text")
+def _extended_text(node: dict) -> str:
+ return _paragraph(node)
+
+
+@_converter("heading")
+def _heading(node: dict) -> str:
+ tag = node.get("tag", "h2")
+ inner = _convert_children(node.get("children", []))
+ if not inner:
+ inner = '""'
+ return f"({tag} {inner})"
+
+
+@_converter("extended-heading")
+def _extended_heading(node: dict) -> str:
+ return _heading(node)
+
+
+@_converter("quote")
+def _quote(node: dict) -> str:
+ inner = _convert_children(node.get("children", []))
+ return f"(blockquote {inner})" if inner else '(blockquote "")'
+
+
+@_converter("extended-quote")
+def _extended_quote(node: dict) -> str:
+ return _quote(node)
+
+
+@_converter("link")
+def _link(node: dict) -> str:
+ href = node.get("url", "")
+ inner = _convert_children(node.get("children", []))
+ if not inner:
+ inner = f'"{_esc(href)}"'
+ return f'(a :href "{_esc(href)}" {inner})'
+
+
+@_converter("autolink")
+def _autolink(node: dict) -> str:
+ return _link(node)
+
+
+@_converter("at-link")
+def _at_link(node: dict) -> str:
+ return _link(node)
+
+
+@_converter("list")
+def _list(node: dict) -> str:
+ tag = "ol" if node.get("listType") == "number" else "ul"
+ inner = _convert_children(node.get("children", []))
+ return f"({tag} {inner})" if inner else f"({tag})"
+
+
+@_converter("listitem")
+def _listitem(node: dict) -> str:
+ inner = _convert_children(node.get("children", []))
+ return f"(li {inner})" if inner else '(li "")'
+
+
+@_converter("horizontalrule")
+def _horizontalrule(_node: dict) -> str:
+ return "(hr)"
+
+
+@_converter("code")
+def _code(node: dict) -> str:
+ inner = _convert_children(node.get("children", []))
+ return f"(code {inner})" if inner else ""
+
+
+@_converter("codeblock")
+def _codeblock(node: dict) -> str:
+ lang = node.get("language", "")
+ code = node.get("code", "")
+ lang_attr = f' :class "language-{_esc(lang)}"' if lang else ""
+ return f'(pre (code{lang_attr} "{_esc(code)}"))'
+
+
+@_converter("code-highlight")
+def _code_highlight(node: dict) -> str:
+ text = node.get("text", "")
+ return f'"{_esc(text)}"' if text else ""
+
+
+# ---------------------------------------------------------------------------
+# Tier 2 — common cards
+# ---------------------------------------------------------------------------
+
+@_converter("image")
+def _image(node: dict) -> str:
+ src = node.get("src", "")
+ alt = node.get("alt", "")
+ caption = node.get("caption", "")
+ width = node.get("cardWidth", "") or node.get("width", "")
+ href = node.get("href", "")
+
+ parts = [f':src "{_esc(src)}"']
+ if alt:
+ parts.append(f':alt "{_esc(alt)}"')
+ if caption:
+ parts.append(f':caption "{_esc(caption)}"')
+ if width:
+ parts.append(f':width "{_esc(width)}"')
+ if href:
+ parts.append(f':href "{_esc(href)}"')
+ return "(~kg-image " + " ".join(parts) + ")"
+
+
+@_converter("gallery")
+def _gallery(node: dict) -> str:
+ images = node.get("images", [])
+ if not images:
+ return ""
+
+ # Group images into rows of 3 (matching lexical_renderer.py)
+ rows = []
+ for i in range(0, len(images), 3):
+ row_imgs = images[i:i + 3]
+ row_items = []
+ for img in row_imgs:
+ item_parts = [f'"src" "{_esc(img.get("src", ""))}"']
+ if img.get("alt"):
+ item_parts.append(f'"alt" "{_esc(img["alt"])}"')
+ if img.get("caption"):
+ item_parts.append(f'"caption" "{_esc(img["caption"])}"')
+ row_items.append("(dict " + " ".join(item_parts) + ")")
+ rows.append("(list " + " ".join(row_items) + ")")
+
+ images_sx = "(list " + " ".join(rows) + ")"
+ caption = node.get("caption", "")
+ caption_attr = f' :caption "{_esc(caption)}"' if caption else ""
+ return f"(~kg-gallery :images {images_sx}{caption_attr})"
+
+
+@_converter("html")
+def _html_card(node: dict) -> str:
+ raw = node.get("html", "")
+ return f'(~kg-html :html "{_esc(raw)}")'
+
+
+@_converter("embed")
+def _embed(node: dict) -> str:
+ embed_html = node.get("html", "")
+ caption = node.get("caption", "")
+ parts = [f':html "{_esc(embed_html)}"']
+ if caption:
+ parts.append(f':caption "{_esc(caption)}"')
+ return "(~kg-embed " + " ".join(parts) + ")"
+
+
+@_converter("bookmark")
+def _bookmark(node: dict) -> str:
+ url = node.get("url", "")
+ meta = node.get("metadata", {})
+ parts = [f':url "{_esc(url)}"']
+
+ title = meta.get("title", "") or node.get("title", "")
+ if title:
+ parts.append(f':title "{_esc(title)}"')
+ desc = meta.get("description", "") or node.get("description", "")
+ if desc:
+ parts.append(f':description "{_esc(desc)}"')
+ icon = meta.get("icon", "") or node.get("icon", "")
+ if icon:
+ parts.append(f':icon "{_esc(icon)}"')
+ author = meta.get("author", "") or node.get("author", "")
+ if author:
+ parts.append(f':author "{_esc(author)}"')
+ publisher = meta.get("publisher", "") or node.get("publisher", "")
+ if publisher:
+ parts.append(f':publisher "{_esc(publisher)}"')
+ thumbnail = meta.get("thumbnail", "") or node.get("thumbnail", "")
+ if thumbnail:
+ parts.append(f':thumbnail "{_esc(thumbnail)}"')
+ caption = node.get("caption", "")
+ if caption:
+ parts.append(f':caption "{_esc(caption)}"')
+
+ return "(~kg-bookmark " + " ".join(parts) + ")"
+
+
+@_converter("callout")
+def _callout(node: dict) -> str:
+ color = node.get("backgroundColor", "grey")
+ emoji = node.get("calloutEmoji", "")
+ inner = _convert_children(node.get("children", []))
+
+ parts = [f':color "{_esc(color)}"']
+ if emoji:
+ parts.append(f':emoji "{_esc(emoji)}"')
+ if inner:
+ parts.append(f':content {inner}')
+ return "(~kg-callout " + " ".join(parts) + ")"
+
+
+@_converter("button")
+def _button(node: dict) -> str:
+ text = node.get("buttonText", "")
+ url = node.get("buttonUrl", "")
+ alignment = node.get("alignment", "center")
+ return f'(~kg-button :url "{_esc(url)}" :text "{_esc(text)}" :alignment "{_esc(alignment)}")'
+
+
+@_converter("toggle")
+def _toggle(node: dict) -> str:
+ heading = node.get("heading", "")
+ inner = _convert_children(node.get("children", []))
+ content_attr = f" :content {inner}" if inner else ""
+ return f'(~kg-toggle :heading "{_esc(heading)}"{content_attr})'
+
+
+@_converter("audio")
+def _audio(node: dict) -> str:
+ src = node.get("src", "")
+ title = node.get("title", "")
+ duration = node.get("duration", 0)
+ thumbnail = node.get("thumbnailSrc", "")
+
+ duration_min = int(duration) // 60
+ duration_sec = int(duration) % 60
+ duration_str = f"{duration_min}:{duration_sec:02d}"
+
+ parts = [f':src "{_esc(src)}"']
+ if title:
+ parts.append(f':title "{_esc(title)}"')
+ parts.append(f':duration "{duration_str}"')
+ if thumbnail:
+ parts.append(f':thumbnail "{_esc(thumbnail)}"')
+ return "(~kg-audio " + " ".join(parts) + ")"
+
+
+@_converter("video")
+def _video(node: dict) -> str:
+ src = node.get("src", "")
+ caption = node.get("caption", "")
+ width = node.get("cardWidth", "")
+ thumbnail = node.get("thumbnailSrc", "") or node.get("customThumbnailSrc", "")
+ loop = node.get("loop", False)
+
+ parts = [f':src "{_esc(src)}"']
+ if caption:
+ parts.append(f':caption "{_esc(caption)}"')
+ if width:
+ parts.append(f':width "{_esc(width)}"')
+ if thumbnail:
+ parts.append(f':thumbnail "{_esc(thumbnail)}"')
+ if loop:
+ parts.append(":loop true")
+ return "(~kg-video " + " ".join(parts) + ")"
+
+
+@_converter("file")
+def _file(node: dict) -> str:
+ src = node.get("src", "")
+ filename = node.get("fileName", "")
+ title = node.get("title", "") or filename
+ file_size = node.get("fileSize", 0)
+ caption = node.get("caption", "")
+
+ # Format size
+ size_str = ""
+ if file_size:
+ kb = file_size / 1024
+ if kb < 1024:
+ size_str = f"{kb:.0f} KB"
+ else:
+ size_str = f"{kb / 1024:.1f} MB"
+
+ parts = [f':src "{_esc(src)}"']
+ if filename:
+ parts.append(f':filename "{_esc(filename)}"')
+ if title:
+ parts.append(f':title "{_esc(title)}"')
+ if size_str:
+ parts.append(f':filesize "{size_str}"')
+ if caption:
+ parts.append(f':caption "{_esc(caption)}"')
+ return "(~kg-file " + " ".join(parts) + ")"
+
+
+@_converter("paywall")
+def _paywall(_node: dict) -> str:
+ return "(~kg-paywall)"
+
+
+@_converter("markdown")
+def _markdown(node: dict) -> str:
+ md_text = node.get("markdown", "")
+ return f'(~kg-html :html "{_esc(md_text)}")'
diff --git a/blog/scripts/backfill_sx_content.py b/blog/scripts/backfill_sx_content.py
new file mode 100644
index 0000000..b1910cb
--- /dev/null
+++ b/blog/scripts/backfill_sx_content.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+"""
+Backfill sx_content from lexical JSON for all posts that have lexical but no sx_content.
+
+Usage:
+ python -m blog.scripts.backfill_sx_content [--dry-run]
+"""
+from __future__ import annotations
+
+import argparse
+import asyncio
+import sys
+
+from sqlalchemy import select, and_
+from sqlalchemy.ext.asyncio import AsyncSession
+
+
+async def backfill(dry_run: bool = False) -> int:
+ from shared.db.sessions import get_session_factory
+ from blog.models.content import Post
+ from blog.bp.blog.ghost.lexical_to_sx import lexical_to_sx
+
+ session_factory = get_session_factory("blog")
+ converted = 0
+ errors = 0
+
+ async with session_factory() as sess:
+ stmt = select(Post).where(
+ and_(
+ Post.lexical.isnot(None),
+ Post.lexical != "",
+ (Post.sx_content.is_(None)) | (Post.sx_content == ""),
+ )
+ )
+ result = await sess.execute(stmt)
+ posts = result.scalars().all()
+
+ print(f"Found {len(posts)} posts to convert")
+
+ for post in posts:
+ try:
+ sx = lexical_to_sx(post.lexical)
+ if dry_run:
+ print(f" [DRY RUN] {post.slug}: {len(sx)} chars")
+ else:
+ post.sx_content = sx
+ print(f" Converted: {post.slug} ({len(sx)} chars)")
+ converted += 1
+ except Exception as e:
+ print(f" ERROR: {post.slug}: {e}", file=sys.stderr)
+ errors += 1
+
+ if not dry_run:
+ await sess.commit()
+
+ print(f"\nDone: {converted} converted, {errors} errors")
+ return converted
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Backfill sx_content from lexical JSON")
+ parser.add_argument("--dry-run", action="store_true", help="Don't write to database")
+ args = parser.parse_args()
+
+ asyncio.run(backfill(dry_run=args.dry_run))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/blog/sx/kg_cards.sx b/blog/sx/kg_cards.sx
new file mode 100644
index 0000000..b55a929
--- /dev/null
+++ b/blog/sx/kg_cards.sx
@@ -0,0 +1,146 @@
+;; KG card components — Ghost/Koenig-compatible card rendering
+;; Produces same HTML structure as lexical_renderer.py so cards.css works unchanged.
+;; Used by both display pipeline and block editor.
+
+;; @css kg-card kg-image-card kg-width-wide kg-width-full kg-gallery-card kg-gallery-container kg-gallery-row kg-gallery-image kg-embed-card kg-bookmark-card kg-bookmark-container kg-bookmark-content kg-bookmark-title kg-bookmark-description kg-bookmark-metadata kg-bookmark-icon kg-bookmark-author kg-bookmark-publisher kg-bookmark-thumbnail kg-callout-card kg-callout-emoji kg-callout-text kg-button-card kg-btn kg-btn-accent kg-toggle-card kg-toggle-heading kg-toggle-heading-text kg-toggle-card-icon kg-toggle-content kg-audio-card kg-audio-thumbnail kg-audio-player-container kg-audio-title kg-audio-player kg-audio-play-icon kg-audio-current-time kg-audio-time kg-audio-seek-slider kg-audio-playback-rate kg-audio-unmute-icon kg-audio-volume-slider kg-video-card kg-video-container kg-file-card kg-file-card-container kg-file-card-contents kg-file-card-title kg-file-card-filesize kg-file-card-icon kg-file-card-caption kg-align-center kg-align-left kg-callout-card-grey kg-callout-card-white kg-callout-card-blue kg-callout-card-green kg-callout-card-yellow kg-callout-card-red kg-callout-card-pink kg-callout-card-purple kg-callout-card-accent placeholder
+
+;; ---------------------------------------------------------------------------
+;; Image card
+;; ---------------------------------------------------------------------------
+(defcomp ~kg-image (&key src alt caption width href)
+ (figure :class (str "kg-card kg-image-card"
+ (if (= width "wide") " kg-width-wide"
+ (if (= width "full") " kg-width-full" "")))
+ (if href
+ (a :href href (img :src src :alt (or alt "") :loading "lazy"))
+ (img :src src :alt (or alt "") :loading "lazy"))
+ (when caption (figcaption caption))))
+
+;; ---------------------------------------------------------------------------
+;; Gallery card
+;; ---------------------------------------------------------------------------
+(defcomp ~kg-gallery (&key images caption)
+ (figure :class "kg-card kg-gallery-card kg-width-wide"
+ (div :class "kg-gallery-container"
+ (map (lambda (row)
+ (div :class "kg-gallery-row"
+ (map (lambda (img-data)
+ (figure :class "kg-gallery-image"
+ (img :src (get img-data "src") :alt (or (get img-data "alt") "") :loading "lazy")
+ (when (get img-data "caption") (figcaption (get img-data "caption")))))
+ row)))
+ images))
+ (when caption (figcaption caption))))
+
+;; ---------------------------------------------------------------------------
+;; HTML card (raw HTML injection)
+;; ---------------------------------------------------------------------------
+(defcomp ~kg-html (&key html)
+ (~rich-text :html html))
+
+;; ---------------------------------------------------------------------------
+;; Embed card
+;; ---------------------------------------------------------------------------
+(defcomp ~kg-embed (&key html caption)
+ (figure :class "kg-card kg-embed-card"
+ (~rich-text :html html)
+ (when caption (figcaption caption))))
+
+;; ---------------------------------------------------------------------------
+;; Bookmark card
+;; ---------------------------------------------------------------------------
+(defcomp ~kg-bookmark (&key url title description icon author publisher thumbnail caption)
+ (figure :class "kg-card kg-bookmark-card"
+ (a :class "kg-bookmark-container" :href url
+ (div :class "kg-bookmark-content"
+ (div :class "kg-bookmark-title" (or title ""))
+ (div :class "kg-bookmark-description" (or description ""))
+ (when (or icon author publisher)
+ (span :class "kg-bookmark-metadata"
+ (when icon (img :class "kg-bookmark-icon" :src icon :alt ""))
+ (when author (span :class "kg-bookmark-author" author))
+ (when publisher (span :class "kg-bookmark-publisher" publisher)))))
+ (when thumbnail
+ (div :class "kg-bookmark-thumbnail"
+ (img :src thumbnail :alt ""))))
+ (when caption (figcaption caption))))
+
+;; ---------------------------------------------------------------------------
+;; Callout card
+;; ---------------------------------------------------------------------------
+(defcomp ~kg-callout (&key color emoji content)
+ (div :class (str "kg-card kg-callout-card kg-callout-card-" (or color "grey"))
+ (when emoji (div :class "kg-callout-emoji" emoji))
+ (div :class "kg-callout-text" (or content ""))))
+
+;; ---------------------------------------------------------------------------
+;; Button card
+;; ---------------------------------------------------------------------------
+(defcomp ~kg-button (&key url text alignment)
+ (div :class (str "kg-card kg-button-card kg-align-" (or alignment "center"))
+ (a :href url :class "kg-btn kg-btn-accent" (or text ""))))
+
+;; ---------------------------------------------------------------------------
+;; Toggle card (accordion)
+;; ---------------------------------------------------------------------------
+(defcomp ~kg-toggle (&key heading content)
+ (div :class "kg-card kg-toggle-card" :data-kg-toggle-state "close"
+ (div :class "kg-toggle-heading"
+ (h4 :class "kg-toggle-heading-text" (or heading ""))
+ (button :class "kg-toggle-card-icon"
+ (~rich-text :html "")))
+ (div :class "kg-toggle-content" (or content ""))))
+
+;; ---------------------------------------------------------------------------
+;; Audio card
+;; ---------------------------------------------------------------------------
+(defcomp ~kg-audio (&key src title duration thumbnail)
+ (div :class "kg-card kg-audio-card"
+ (if thumbnail
+ (img :src thumbnail :alt "audio-thumbnail" :class "kg-audio-thumbnail")
+ (div :class "kg-audio-thumbnail placeholder"
+ (~rich-text :html "")))
+ (div :class "kg-audio-player-container"
+ (div :class "kg-audio-title" (or title ""))
+ (div :class "kg-audio-player"
+ (button :class "kg-audio-play-icon"
+ (~rich-text :html ""))
+ (div :class "kg-audio-current-time" "0:00")
+ (div :class "kg-audio-time" (str "/ " (or duration "0:00")))
+ (input :type "range" :class "kg-audio-seek-slider" :max "100" :value "0")
+ (button :class "kg-audio-playback-rate" "1×")
+ (button :class "kg-audio-unmute-icon"
+ (~rich-text :html ""))
+ (input :type "range" :class "kg-audio-volume-slider" :max "100" :value "100")))
+ (audio :src src :preload "metadata")))
+
+;; ---------------------------------------------------------------------------
+;; Video card
+;; ---------------------------------------------------------------------------
+(defcomp ~kg-video (&key src caption width thumbnail loop)
+ (figure :class (str "kg-card kg-video-card"
+ (if (= width "wide") " kg-width-wide"
+ (if (= width "full") " kg-width-full" "")))
+ (div :class "kg-video-container"
+ (video :src src :controls true :preload "metadata"
+ :poster (or thumbnail nil) :loop (or loop nil)))
+ (when caption (figcaption caption))))
+
+;; ---------------------------------------------------------------------------
+;; File card
+;; ---------------------------------------------------------------------------
+(defcomp ~kg-file (&key src filename title filesize caption)
+ (div :class "kg-card kg-file-card"
+ (a :class "kg-file-card-container" :href src :download (or filename "")
+ (div :class "kg-file-card-contents"
+ (div :class "kg-file-card-title" (or title filename ""))
+ (when filesize (div :class "kg-file-card-filesize" filesize)))
+ (div :class "kg-file-card-icon"
+ (~rich-text :html "")))
+ (when caption (div :class "kg-file-card-caption" caption))))
+
+;; ---------------------------------------------------------------------------
+;; Paywall marker
+;; ---------------------------------------------------------------------------
+(defcomp ~kg-paywall ()
+ (~rich-text :html ""))
diff --git a/blog/tests/test_lexical_to_sx.py b/blog/tests/test_lexical_to_sx.py
new file mode 100644
index 0000000..973b0e7
--- /dev/null
+++ b/blog/tests/test_lexical_to_sx.py
@@ -0,0 +1,278 @@
+"""Unit tests for the Lexical JSON → sx converter."""
+from __future__ import annotations
+
+import sys
+import os
+import pytest
+
+# The lexical_to_sx module is standalone (only depends on json).
+# Import it directly to avoid pulling in the full blog app.
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bp", "blog", "ghost"))
+from lexical_to_sx import lexical_to_sx
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _doc(*children):
+ """Wrap children in a minimal Lexical document."""
+ return {"root": {"children": list(children)}}
+
+
+def _text(s, fmt=0):
+ return {"type": "text", "text": s, "format": fmt}
+
+
+def _paragraph(*children):
+ return {"type": "paragraph", "children": list(children)}
+
+
+def _heading(tag, *children):
+ return {"type": "heading", "tag": tag, "children": list(children)}
+
+
+def _link(url, *children):
+ return {"type": "link", "url": url, "children": list(children)}
+
+
+# ---------------------------------------------------------------------------
+# Basic text
+# ---------------------------------------------------------------------------
+
+class TestBasicText:
+ def test_empty_doc(self):
+ result = lexical_to_sx(_doc())
+ assert result == '(<> (p ""))'
+
+ def test_single_paragraph(self):
+ result = lexical_to_sx(_doc(_paragraph(_text("Hello"))))
+ assert result == '(p "Hello")'
+
+ def test_two_paragraphs(self):
+ result = lexical_to_sx(_doc(
+ _paragraph(_text("Hello")),
+ _paragraph(_text("World")),
+ ))
+ assert "(p " in result
+ assert '"Hello"' in result
+ assert '"World"' in result
+
+ def test_heading(self):
+ result = lexical_to_sx(_doc(_heading("h2", _text("Title"))))
+ assert result == '(h2 "Title")'
+
+ def test_h3(self):
+ result = lexical_to_sx(_doc(_heading("h3", _text("Sub"))))
+ assert result == '(h3 "Sub")'
+
+
+# ---------------------------------------------------------------------------
+# Formatting
+# ---------------------------------------------------------------------------
+
+class TestFormatting:
+ def test_bold(self):
+ result = lexical_to_sx(_doc(_paragraph(_text("hi", 1))))
+ assert "(strong " in result
+
+ def test_italic(self):
+ result = lexical_to_sx(_doc(_paragraph(_text("hi", 2))))
+ assert "(em " in result
+
+ def test_strikethrough(self):
+ result = lexical_to_sx(_doc(_paragraph(_text("hi", 4))))
+ assert "(s " in result
+
+ def test_bold_italic(self):
+ result = lexical_to_sx(_doc(_paragraph(_text("hi", 3))))
+ assert "(strong " in result
+ assert "(em " in result
+
+ def test_code(self):
+ result = lexical_to_sx(_doc(_paragraph(_text("x", 16))))
+ assert "(code " in result
+
+
+# ---------------------------------------------------------------------------
+# Links
+# ---------------------------------------------------------------------------
+
+class TestLinks:
+ def test_link(self):
+ result = lexical_to_sx(_doc(
+ _paragraph(_link("https://example.com", _text("click")))
+ ))
+ assert '(a :href "https://example.com"' in result
+ assert '"click"' in result
+
+
+# ---------------------------------------------------------------------------
+# Lists
+# ---------------------------------------------------------------------------
+
+class TestLists:
+ def test_unordered_list(self):
+ result = lexical_to_sx(_doc({
+ "type": "list", "listType": "bullet",
+ "children": [
+ {"type": "listitem", "children": [_text("one")]},
+ {"type": "listitem", "children": [_text("two")]},
+ ]
+ }))
+ assert "(ul " in result
+ assert "(li " in result
+ assert '"one"' in result
+
+ def test_ordered_list(self):
+ result = lexical_to_sx(_doc({
+ "type": "list", "listType": "number",
+ "children": [
+ {"type": "listitem", "children": [_text("first")]},
+ ]
+ }))
+ assert "(ol " in result
+
+
+# ---------------------------------------------------------------------------
+# Block elements
+# ---------------------------------------------------------------------------
+
+class TestBlocks:
+ def test_hr(self):
+ result = lexical_to_sx(_doc({"type": "horizontalrule"}))
+ assert result == "(hr)"
+
+ def test_quote(self):
+ result = lexical_to_sx(_doc({
+ "type": "quote", "children": [_text("wisdom")]
+ }))
+ assert '(blockquote "wisdom")' == result
+
+ def test_codeblock(self):
+ result = lexical_to_sx(_doc({
+ "type": "codeblock", "code": "print('hi')", "language": "python"
+ }))
+ assert '(pre (code :class "language-python"' in result
+ assert "print" in result
+
+
+# ---------------------------------------------------------------------------
+# Cards
+# ---------------------------------------------------------------------------
+
+class TestCards:
+ def test_image(self):
+ result = lexical_to_sx(_doc({
+ "type": "image", "src": "photo.jpg", "alt": "test"
+ }))
+ assert '(~kg-image :src "photo.jpg" :alt "test")' == result
+
+ def test_image_wide_with_caption(self):
+ result = lexical_to_sx(_doc({
+ "type": "image", "src": "p.jpg", "alt": "",
+ "cardWidth": "wide", "caption": "Fig 1"
+ }))
+ assert ':width "wide"' in result
+ assert ':caption "Fig 1"' in result
+
+ def test_bookmark(self):
+ result = lexical_to_sx(_doc({
+ "type": "bookmark", "url": "https://example.com",
+ "metadata": {"title": "Example", "description": "A site"}
+ }))
+ assert "(~kg-bookmark " in result
+ assert ':url "https://example.com"' in result
+ assert ':title "Example"' in result
+
+ def test_callout(self):
+ result = lexical_to_sx(_doc({
+ "type": "callout", "backgroundColor": "blue",
+ "calloutEmoji": "💡",
+ "children": [_text("Note")]
+ }))
+ assert "(~kg-callout " in result
+ assert ':color "blue"' in result
+
+ def test_button(self):
+ result = lexical_to_sx(_doc({
+ "type": "button", "buttonText": "Click",
+ "buttonUrl": "https://example.com"
+ }))
+ assert "(~kg-button " in result
+ assert ':text "Click"' in result
+
+ def test_toggle(self):
+ result = lexical_to_sx(_doc({
+ "type": "toggle", "heading": "FAQ",
+ "children": [_text("Answer")]
+ }))
+ assert "(~kg-toggle " in result
+ assert ':heading "FAQ"' in result
+
+ def test_html(self):
+ result = lexical_to_sx(_doc({
+ "type": "html", "html": "
custom
"
+ }))
+ assert "(~kg-html " in result
+
+ def test_embed(self):
+ result = lexical_to_sx(_doc({
+ "type": "embed", "html": "",
+ "caption": "Video"
+ }))
+ assert "(~kg-embed " in result
+ assert ':caption "Video"' in result
+
+ def test_video(self):
+ result = lexical_to_sx(_doc({
+ "type": "video", "src": "v.mp4", "cardWidth": "wide"
+ }))
+ assert "(~kg-video " in result
+ assert ':width "wide"' in result
+
+ def test_audio(self):
+ result = lexical_to_sx(_doc({
+ "type": "audio", "src": "s.mp3", "title": "Song", "duration": 195
+ }))
+ assert "(~kg-audio " in result
+ assert ':duration "3:15"' in result
+
+ def test_file(self):
+ result = lexical_to_sx(_doc({
+ "type": "file", "src": "f.pdf", "fileName": "doc.pdf",
+ "fileSize": 2100000
+ }))
+ assert "(~kg-file " in result
+ assert ':filename "doc.pdf"' in result
+ assert "MB" in result
+
+ def test_paywall(self):
+ result = lexical_to_sx(_doc({"type": "paywall"}))
+ assert result == "(~kg-paywall)"
+
+
+# ---------------------------------------------------------------------------
+# Escaping
+# ---------------------------------------------------------------------------
+
+class TestEscaping:
+ def test_quotes_in_text(self):
+ result = lexical_to_sx(_doc(_paragraph(_text('He said "hello"'))))
+ assert '\\"hello\\"' in result
+
+ def test_backslash_in_text(self):
+ result = lexical_to_sx(_doc(_paragraph(_text("a\\b"))))
+ assert "a\\\\b" in result
+
+
+# ---------------------------------------------------------------------------
+# JSON string input
+# ---------------------------------------------------------------------------
+
+class TestJsonString:
+ def test_string_input(self):
+ import json
+ doc = _doc(_paragraph(_text("test")))
+ result = lexical_to_sx(json.dumps(doc))
+ assert '(p "test")' == result
diff --git a/shared/static/scripts/sx-editor.js b/shared/static/scripts/sx-editor.js
new file mode 100644
index 0000000..1d5254c
--- /dev/null
+++ b/shared/static/scripts/sx-editor.js
@@ -0,0 +1,2290 @@
+/**
+ * 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)) + "" + t + ">";
+ } else {
+ html += escHtml(sxToString(e));
+ }
+ }
+ }
+ return html;
+ }
+
+ function extractKwargs(args) {
+ var kw = {};
+ for (var i = 0; i < args.length; i++) {
+ if (args[i] && typeof args[i] === "object" && args[i].name && args[i].constructor &&
+ args[i].constructor === Sx.Keyword) {
+ kw[args[i].name] = args[i + 1];
+ i++;
+ }
+ }
+ return kw;
+ }
+
+ function extractChildren(args) {
+ var children = [];
+ for (var i = 0; i < args.length; i++) {
+ if (args[i] && typeof args[i] === "object" && args[i].name && args[i].constructor &&
+ args[i].constructor === Sx.Keyword) {
+ i++;
+ } else {
+ children.push(args[i]);
+ }
+ }
+ return children;
+ }
+
+ function sxToString(expr) {
+ if (typeof expr === "string") return expr;
+ if (Array.isArray(expr)) return expr.map(sxToString).join("");
+ return String(expr);
+ }
+
+ // =========================================================================
+ // Block creation
+ // =========================================================================
+
+ function createTextBlock(tag, htmlContent) {
+ var wrapper = el("div", {
+ className: "sx-block sx-block-text",
+ "data-sx-block": "true",
+ "data-sx-tag": tag
+ });
+
+ var editable = el("div", {
+ contenteditable: "true",
+ className: "sx-block-content sx-editable" + (tag === "h2" || tag === "h3" ? " sx-heading" : "") +
+ (tag === "blockquote" ? " sx-quote" : ""),
+ "data-placeholder": tag === "p" ? "Type / for commands..." :
+ tag === "blockquote" ? "Type a quote..." :
+ "Type a heading...",
+ "data-block-type": tag
+ });
+ editable.innerHTML = htmlContent || "";
+
+ wrapper.appendChild(editable);
+ return wrapper;
+ }
+
+ function createListBlock(tag, items) {
+ var wrapper = el("div", {
+ className: "sx-block sx-block-list",
+ "data-sx-block": "true",
+ "data-sx-tag": tag
+ });
+
+ var listEl = el("div", {
+ className: "sx-block-content sx-list-content",
+ "data-list-type": tag
+ });
+
+ if (items && items.length) {
+ for (var i = 0; i < items.length; i++) {
+ var item = items[i];
+ if (Array.isArray(item) && item[0] && item[0].name === "li") {
+ var li = el("div", {
+ contenteditable: "true",
+ className: "sx-list-item sx-editable",
+ "data-sx-li": "true",
+ "data-placeholder": "List item..."
+ });
+ li.innerHTML = renderInlineToHtml(item.slice(1));
+ listEl.appendChild(li);
+ }
+ }
+ }
+ if (!listEl.children.length) {
+ var li = el("div", {
+ contenteditable: "true",
+ className: "sx-list-item sx-editable",
+ "data-sx-li": "true",
+ "data-placeholder": "List item..."
+ });
+ listEl.appendChild(li);
+ }
+
+ wrapper.appendChild(listEl);
+ return wrapper;
+ }
+
+ function createHrBlock() {
+ var wrapper = el("div", {
+ className: "sx-block sx-block-hr",
+ "data-sx-block": "true",
+ "data-sx-tag": "hr"
+ });
+ wrapper.appendChild(el("hr", { className: "sx-hr" }));
+ return wrapper;
+ }
+
+ function createCodeBlock(code, lang) {
+ var wrapper = el("div", {
+ className: "sx-block sx-block-code",
+ "data-sx-block": "true",
+ "data-sx-tag": "pre",
+ "data-sx-lang": lang || ""
+ });
+
+ var header = el("div", { className: "sx-code-header" });
+ var langInput = el("input", {
+ type: "text",
+ className: "sx-code-lang",
+ placeholder: "Language",
+ value: lang || ""
+ });
+ langInput.addEventListener("input", function () {
+ wrapper.setAttribute("data-sx-lang", langInput.value);
+ });
+ header.appendChild(langInput);
+
+ var textarea = el("textarea", {
+ className: "sx-code-textarea",
+ spellcheck: "false",
+ placeholder: "Paste or type code..."
+ }, code || "");
+
+ function autoResize() {
+ textarea.style.height = "auto";
+ textarea.style.height = textarea.scrollHeight + "px";
+ }
+ textarea.addEventListener("input", autoResize);
+ setTimeout(autoResize, 0);
+
+ wrapper.appendChild(header);
+ wrapper.appendChild(textarea);
+ return wrapper;
+ }
+
+ // =========================================================================
+ // Card blocks with edit/preview modes
+ // =========================================================================
+
+ /**
+ * Create a card block. Cards have two modes:
+ * - Preview mode: rendered ~kg-* component output
+ * - Edit mode: card-specific editing UI
+ *
+ * Click to enter edit mode, click outside to return to preview.
+ */
+ function createCardBlock(cardType, attrs) {
+ attrs = attrs || {};
+ var wrapper = el("div", {
+ className: "sx-block sx-block-card",
+ "data-sx-block": "true",
+ "data-sx-tag": "card",
+ "data-sx-card": cardType,
+ "data-sx-attrs": JSON.stringify(attrs)
+ });
+
+ var preview = el("div", { className: "sx-card-preview" });
+ var editPanel = el("div", { className: "sx-card-edit", style: "display:none" });
+
+ // Render preview
+ renderCardPreview(cardType, attrs, preview);
+
+ // Build edit UI based on card type
+ buildCardEditUI(cardType, attrs, editPanel, wrapper, preview);
+
+ // Card toolbar (delete, width controls)
+ var toolbar = el("div", { className: "sx-card-toolbar" });
+ var deleteBtn = el("button", {
+ type: "button", className: "sx-card-tool-btn", title: "Delete card"
+ });
+ deleteBtn.innerHTML = '';
+ toolbar.appendChild(deleteBtn);
+
+ wrapper.appendChild(toolbar);
+ wrapper.appendChild(preview);
+ wrapper.appendChild(editPanel);
+
+ // Click preview → enter edit mode
+ preview.addEventListener("click", function (e) {
+ if (e.target.closest("[data-sx-caption]")) return; // caption is always editable
+ wrapper.classList.add("sx-card-editing");
+ preview.style.display = "none";
+ editPanel.style.display = "block";
+ // Focus first input in edit panel
+ var firstInput = editPanel.querySelector("input, textarea, [contenteditable]");
+ if (firstInput) firstInput.focus();
+ });
+
+ // Add caption for applicable card types
+ var captionTypes = {
+ "kg-image": true, "kg-gallery": true, "kg-embed": true,
+ "kg-bookmark": true, "kg-video": true
+ };
+ 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 = '' +
+ '' +
+ '';
+ 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 = '' +
+ '';
+ 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 = '' +
+ '' +
+ '';
+ 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
+ };
+
+})();