From 341fc4cf2807c8a43872cdf1dacdd391e4969a46 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 1 Mar 2026 23:17:49 +0000 Subject: [PATCH] Add SX block editor with Koenig-quality controls and lexical-to-sx converter Pure s-expression block editor replacing React/Koenig: single hover + button, slash commands, full card edit modes (image/gallery/video/audio/file/embed/ bookmark/callout/toggle/button/HTML/code), inline format toolbar, keyboard shortcuts, drag-drop uploads, oEmbed/bookmark metadata fetching. Includes lexical_to_sx converter for backfilling existing posts, KG card components matching Ghost's card CSS, migration for sx_content column, and 31 converter tests. Co-Authored-By: Claude Opus 4.6 --- blog/alembic/versions/0005_add_sx_content.py | 20 + blog/bp/blog/ghost/lexical_to_sx.py | 430 ++++ blog/scripts/backfill_sx_content.py | 69 + blog/sx/kg_cards.sx | 146 ++ blog/tests/test_lexical_to_sx.py | 278 +++ shared/static/scripts/sx-editor.js | 2290 ++++++++++++++++++ 6 files changed, 3233 insertions(+) create mode 100644 blog/alembic/versions/0005_add_sx_content.py create mode 100644 blog/bp/blog/ghost/lexical_to_sx.py create mode 100644 blog/scripts/backfill_sx_content.py create mode 100644 blog/sx/kg_cards.sx create mode 100644 blog/tests/test_lexical_to_sx.py create mode 100644 shared/static/scripts/sx-editor.js 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)) + ""; + } else { + html += escHtml(sxToString(e)); + } + } + } + return html; + } + + function extractKwargs(args) { + var kw = {}; + for (var i = 0; i < args.length; i++) { + if (args[i] && typeof args[i] === "object" && args[i].name && args[i].constructor && + args[i].constructor === Sx.Keyword) { + kw[args[i].name] = args[i + 1]; + i++; + } + } + return kw; + } + + function extractChildren(args) { + var children = []; + for (var i = 0; i < args.length; i++) { + if (args[i] && typeof args[i] === "object" && args[i].name && args[i].constructor && + args[i].constructor === Sx.Keyword) { + i++; + } else { + children.push(args[i]); + } + } + return children; + } + + function sxToString(expr) { + if (typeof expr === "string") return expr; + if (Array.isArray(expr)) return expr.map(sxToString).join(""); + return String(expr); + } + + // ========================================================================= + // Block creation + // ========================================================================= + + function createTextBlock(tag, htmlContent) { + var wrapper = el("div", { + className: "sx-block sx-block-text", + "data-sx-block": "true", + "data-sx-tag": tag + }); + + var editable = el("div", { + contenteditable: "true", + className: "sx-block-content sx-editable" + (tag === "h2" || tag === "h3" ? " sx-heading" : "") + + (tag === "blockquote" ? " sx-quote" : ""), + "data-placeholder": tag === "p" ? "Type / for commands..." : + tag === "blockquote" ? "Type a quote..." : + "Type a heading...", + "data-block-type": tag + }); + editable.innerHTML = htmlContent || ""; + + wrapper.appendChild(editable); + return wrapper; + } + + function createListBlock(tag, items) { + var wrapper = el("div", { + className: "sx-block sx-block-list", + "data-sx-block": "true", + "data-sx-tag": tag + }); + + var listEl = el("div", { + className: "sx-block-content sx-list-content", + "data-list-type": tag + }); + + if (items && items.length) { + for (var i = 0; i < items.length; i++) { + var item = items[i]; + if (Array.isArray(item) && item[0] && item[0].name === "li") { + var li = el("div", { + contenteditable: "true", + className: "sx-list-item sx-editable", + "data-sx-li": "true", + "data-placeholder": "List item..." + }); + li.innerHTML = renderInlineToHtml(item.slice(1)); + listEl.appendChild(li); + } + } + } + if (!listEl.children.length) { + var li = el("div", { + contenteditable: "true", + className: "sx-list-item sx-editable", + "data-sx-li": "true", + "data-placeholder": "List item..." + }); + listEl.appendChild(li); + } + + wrapper.appendChild(listEl); + return wrapper; + } + + function createHrBlock() { + var wrapper = el("div", { + className: "sx-block sx-block-hr", + "data-sx-block": "true", + "data-sx-tag": "hr" + }); + wrapper.appendChild(el("hr", { className: "sx-hr" })); + return wrapper; + } + + function createCodeBlock(code, lang) { + var wrapper = el("div", { + className: "sx-block sx-block-code", + "data-sx-block": "true", + "data-sx-tag": "pre", + "data-sx-lang": lang || "" + }); + + var header = el("div", { className: "sx-code-header" }); + var langInput = el("input", { + type: "text", + className: "sx-code-lang", + placeholder: "Language", + value: lang || "" + }); + langInput.addEventListener("input", function () { + wrapper.setAttribute("data-sx-lang", langInput.value); + }); + header.appendChild(langInput); + + var textarea = el("textarea", { + className: "sx-code-textarea", + spellcheck: "false", + placeholder: "Paste or type code..." + }, code || ""); + + function autoResize() { + textarea.style.height = "auto"; + textarea.style.height = textarea.scrollHeight + "px"; + } + textarea.addEventListener("input", autoResize); + setTimeout(autoResize, 0); + + wrapper.appendChild(header); + wrapper.appendChild(textarea); + return wrapper; + } + + // ========================================================================= + // Card blocks with edit/preview modes + // ========================================================================= + + /** + * Create a card block. Cards have two modes: + * - Preview mode: rendered ~kg-* component output + * - Edit mode: card-specific editing UI + * + * Click to enter edit mode, click outside to return to preview. + */ + function createCardBlock(cardType, attrs) { + attrs = attrs || {}; + var wrapper = el("div", { + className: "sx-block sx-block-card", + "data-sx-block": "true", + "data-sx-tag": "card", + "data-sx-card": cardType, + "data-sx-attrs": JSON.stringify(attrs) + }); + + var preview = el("div", { className: "sx-card-preview" }); + var editPanel = el("div", { className: "sx-card-edit", style: "display:none" }); + + // Render preview + renderCardPreview(cardType, attrs, preview); + + // Build edit UI based on card type + buildCardEditUI(cardType, attrs, editPanel, wrapper, preview); + + // Card toolbar (delete, width controls) + var toolbar = el("div", { className: "sx-card-toolbar" }); + var deleteBtn = el("button", { + type: "button", className: "sx-card-tool-btn", title: "Delete card" + }); + deleteBtn.innerHTML = ''; + toolbar.appendChild(deleteBtn); + + wrapper.appendChild(toolbar); + wrapper.appendChild(preview); + wrapper.appendChild(editPanel); + + // Click preview → enter edit mode + preview.addEventListener("click", function (e) { + if (e.target.closest("[data-sx-caption]")) return; // caption is always editable + wrapper.classList.add("sx-card-editing"); + preview.style.display = "none"; + editPanel.style.display = "block"; + // Focus first input in edit panel + var firstInput = editPanel.querySelector("input, textarea, [contenteditable]"); + if (firstInput) firstInput.focus(); + }); + + // Add caption for applicable card types + var captionTypes = { + "kg-image": true, "kg-gallery": true, "kg-embed": true, + "kg-bookmark": true, "kg-video": true + }; + if (captionTypes[cardType]) { + var captionEl = el("div", { + contenteditable: "true", + className: "sx-card-caption sx-editable", + "data-sx-caption": "true", + "data-placeholder": "Type caption for image (optional)" + }); + if (attrs.caption) captionEl.textContent = attrs.caption; + wrapper.appendChild(captionEl); + } + + return wrapper; + } + + function renderCardPreview(cardType, attrs, container) { + container.innerHTML = ""; + try { + var sxSource = buildCardSx(cardType, attrs); + var dom = Sx.render(sxSource); + container.appendChild(dom); + } catch (e) { + container.innerHTML = '
[' + escHtml(cardType) + ' card]
'; + } + } + + function buildCardSx(cardType, attrs) { + var parts = ["(~" + cardType]; + for (var k in attrs) { + if (attrs[k] === null || attrs[k] === undefined || attrs[k] === false || attrs[k] === "") continue; + if (attrs[k] === true) { + parts.push(":" + k + " true"); + } else { + parts.push(':' + k + ' "' + escSx(String(attrs[k])) + '"'); + } + } + parts.push(")"); + return parts.join(" "); + } + + function updateCardAttrs(wrapper, newAttrs) { + wrapper.setAttribute("data-sx-attrs", JSON.stringify(newAttrs)); + } + + function exitCardEdit(wrapper) { + var preview = wrapper.querySelector(".sx-card-preview"); + var editPanel = wrapper.querySelector(".sx-card-edit"); + var attrs; + try { attrs = JSON.parse(wrapper.getAttribute("data-sx-attrs") || "{}"); } catch (e) { attrs = {}; } + var cardType = wrapper.getAttribute("data-sx-card"); + + wrapper.classList.remove("sx-card-editing"); + renderCardPreview(cardType, attrs, preview); + preview.style.display = "block"; + editPanel.style.display = "none"; + } + + // ========================================================================= + // Card edit UIs + // ========================================================================= + + function buildCardEditUI(cardType, attrs, editPanel, wrapper, previewEl) { + switch (cardType) { + case "kg-image": buildImageEditUI(attrs, editPanel, wrapper); break; + case "kg-gallery": buildGalleryEditUI(attrs, editPanel, wrapper); break; + case "kg-html": buildHtmlEditUI(attrs, editPanel, wrapper); break; + case "kg-embed": buildEmbedEditUI(attrs, editPanel, wrapper); break; + case "kg-bookmark": buildBookmarkEditUI(attrs, editPanel, wrapper); break; + case "kg-callout": buildCalloutEditUI(attrs, editPanel, wrapper); break; + case "kg-toggle": buildToggleEditUI(attrs, editPanel, wrapper); break; + case "kg-button": buildButtonEditUI(attrs, editPanel, wrapper); break; + case "kg-audio": buildAudioEditUI(attrs, editPanel, wrapper); break; + case "kg-video": buildVideoEditUI(attrs, editPanel, wrapper); break; + case "kg-file": buildFileEditUI(attrs, editPanel, wrapper); break; + default: buildGenericEditUI(attrs, editPanel, wrapper); break; + } + } + + // -- Image card edit UI -- + function buildImageEditUI(attrs, panel, wrapper) { + if (attrs.src) { + // Has image — show it with controls + var img = el("img", { src: attrs.src, className: "sx-edit-img-preview" }); + panel.appendChild(img); + + var controls = el("div", { className: "sx-edit-controls" }); + + var altRow = makeInputRow("Alt text", attrs.alt || "", function (v) { + attrs.alt = v; updateCardAttrs(wrapper, attrs); + }); + controls.appendChild(altRow); + + var widthRow = makeSelectRow("Width", attrs.width || "", [ + { value: "", label: "Normal" }, + { value: "wide", label: "Wide" }, + { value: "full", label: "Full" }, + ], function (v) { + attrs.width = v; updateCardAttrs(wrapper, attrs); + }); + controls.appendChild(widthRow); + + var hrefRow = makeInputRow("Link URL", attrs.href || "", function (v) { + attrs.href = v; updateCardAttrs(wrapper, attrs); + }); + controls.appendChild(hrefRow); + + var replaceBtn = el("button", { + type: "button", className: "sx-edit-btn sx-edit-btn-sm" + }, "Replace image"); + replaceBtn.addEventListener("click", function () { + triggerFileUpload(wrapper, "image", function (url) { + attrs.src = url; + updateCardAttrs(wrapper, attrs); + img.src = url; + }); + }); + controls.appendChild(replaceBtn); + + panel.appendChild(controls); + } else { + // No image yet — show upload area + buildUploadArea(panel, wrapper, "image", "Drop image here or click to upload", function (url) { + attrs.src = url; + updateCardAttrs(wrapper, attrs); + panel.innerHTML = ""; + buildImageEditUI(attrs, panel, wrapper); + }); + } + } + + // -- Gallery card edit UI -- + function buildGalleryEditUI(attrs, panel, wrapper) { + var images = attrs.images || []; + panel.innerHTML = ""; + + if (images.length) { + var grid = el("div", { className: "sx-edit-gallery-grid" }); + // We store images as a stringified JSON in the `images` attr for now + var imgList; + try { imgList = typeof images === "string" ? JSON.parse(images) : images; } catch (e) { imgList = []; } + + for (var i = 0; i < imgList.length; i++) { + (function (idx) { + var thumb = el("div", { className: "sx-edit-gallery-thumb" }); + var img = el("img", { src: imgList[idx].src || imgList[idx] }); + var removeBtn = el("button", { + type: "button", className: "sx-edit-gallery-remove", title: "Remove" + }); + removeBtn.innerHTML = "×"; + removeBtn.addEventListener("click", function () { + imgList.splice(idx, 1); + attrs.images = imgList; + updateCardAttrs(wrapper, attrs); + buildGalleryEditUI(attrs, panel, wrapper); + }); + thumb.appendChild(img); + thumb.appendChild(removeBtn); + grid.appendChild(thumb); + })(i); + } + panel.appendChild(grid); + } + + var addBtn = el("button", { + type: "button", className: "sx-edit-btn" + }, "+ Add images"); + addBtn.addEventListener("click", function () { + triggerFileUpload(wrapper, "image", function (url) { + var imgList; + try { imgList = typeof attrs.images === "string" ? JSON.parse(attrs.images) : (attrs.images || []); } catch (e) { imgList = []; } + imgList.push({ src: url, alt: "" }); + attrs.images = imgList; + updateCardAttrs(wrapper, attrs); + buildGalleryEditUI(attrs, panel, wrapper); + }, true); + }); + panel.appendChild(addBtn); + } + + // -- HTML card edit UI -- + function buildHtmlEditUI(attrs, panel, wrapper) { + var textarea = el("textarea", { + className: "sx-edit-html-textarea", + placeholder: "Paste HTML here...", + spellcheck: "false" + }, attrs.html || ""); + + function autoResize() { + textarea.style.height = "auto"; + textarea.style.height = Math.max(120, textarea.scrollHeight) + "px"; + } + + textarea.addEventListener("input", function () { + attrs.html = textarea.value; + updateCardAttrs(wrapper, attrs); + autoResize(); + }); + setTimeout(autoResize, 0); + + panel.appendChild(textarea); + } + + // -- Embed card edit UI -- + function buildEmbedEditUI(attrs, panel, wrapper) { + if (attrs.html) { + // Already have embed HTML + var previewDiv = el("div", { className: "sx-edit-embed-preview" }); + previewDiv.innerHTML = attrs.html; + panel.appendChild(previewDiv); + + if (attrs.url) { + var urlDisplay = el("div", { className: "sx-edit-url-display" }, attrs.url); + panel.appendChild(urlDisplay); + } + + var reloadBtn = el("button", { + type: "button", className: "sx-edit-btn sx-edit-btn-sm" + }, "Change URL"); + reloadBtn.addEventListener("click", function () { + attrs.html = ""; + attrs.url = ""; + updateCardAttrs(wrapper, attrs); + panel.innerHTML = ""; + buildEmbedEditUI(attrs, panel, wrapper); + }); + panel.appendChild(reloadBtn); + } else { + // URL input for oEmbed lookup + var urlInput = el("input", { + type: "url", + className: "sx-edit-input sx-edit-url-input", + placeholder: "Paste URL to embed...", + value: attrs.url || "" + }); + + var status = el("div", { className: "sx-edit-status" }); + + urlInput.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + e.preventDefault(); + fetchOembed(wrapper, urlInput.value, attrs, panel, status); + } + }); + + var fetchBtn = el("button", { + type: "button", className: "sx-edit-btn" + }, "Embed"); + fetchBtn.addEventListener("click", function () { + fetchOembed(wrapper, urlInput.value, attrs, panel, status); + }); + + panel.appendChild(urlInput); + panel.appendChild(fetchBtn); + panel.appendChild(status); + } + } + + // -- Bookmark card edit UI -- + function buildBookmarkEditUI(attrs, panel, wrapper) { + if (attrs.url && attrs.title) { + // Already fetched — show editable fields + var controls = el("div", { className: "sx-edit-controls" }); + + controls.appendChild(makeInputRow("URL", attrs.url || "", function (v) { + attrs.url = v; updateCardAttrs(wrapper, attrs); + })); + controls.appendChild(makeInputRow("Title", attrs.title || "", function (v) { + attrs.title = v; updateCardAttrs(wrapper, attrs); + })); + controls.appendChild(makeInputRow("Description", attrs.description || "", function (v) { + attrs.description = v; updateCardAttrs(wrapper, attrs); + })); + controls.appendChild(makeInputRow("Author", attrs.author || "", function (v) { + attrs.author = v; updateCardAttrs(wrapper, attrs); + })); + controls.appendChild(makeInputRow("Publisher", attrs.publisher || "", function (v) { + attrs.publisher = v; updateCardAttrs(wrapper, attrs); + })); + controls.appendChild(makeInputRow("Thumbnail URL", attrs.thumbnail || "", function (v) { + attrs.thumbnail = v; updateCardAttrs(wrapper, attrs); + })); + controls.appendChild(makeInputRow("Icon URL", attrs.icon || "", function (v) { + attrs.icon = v; updateCardAttrs(wrapper, attrs); + })); + + var refetchBtn = el("button", { + type: "button", className: "sx-edit-btn sx-edit-btn-sm" + }, "Re-fetch metadata"); + refetchBtn.addEventListener("click", function () { + attrs.title = ""; attrs.description = ""; attrs.author = ""; attrs.publisher = ""; + attrs.thumbnail = ""; attrs.icon = ""; + updateCardAttrs(wrapper, attrs); + panel.innerHTML = ""; + buildBookmarkEditUI(attrs, panel, wrapper); + }); + controls.appendChild(refetchBtn); + + panel.appendChild(controls); + } else { + // URL input for metadata fetch + var urlInput = el("input", { + type: "url", + className: "sx-edit-input sx-edit-url-input", + placeholder: "Paste URL for bookmark...", + value: attrs.url || "" + }); + var status = el("div", { className: "sx-edit-status" }); + + urlInput.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + e.preventDefault(); + fetchBookmark(wrapper, urlInput.value, attrs, panel, status); + } + }); + + var fetchBtn = el("button", { + type: "button", className: "sx-edit-btn" + }, "Add bookmark"); + fetchBtn.addEventListener("click", function () { + fetchBookmark(wrapper, urlInput.value, attrs, panel, status); + }); + + panel.appendChild(urlInput); + panel.appendChild(fetchBtn); + panel.appendChild(status); + } + } + + // -- Callout card edit UI -- + function buildCalloutEditUI(attrs, panel, wrapper) { + var colors = ["grey", "white", "blue", "green", "yellow", "red", "pink", "purple"]; + var colorMap = { + grey: "#f1f1f1", white: "#ffffff", blue: "#e8f0fe", green: "#e6f4ea", + yellow: "#fef7cd", red: "#fce8e6", pink: "#fce4ec", purple: "#f3e8fd" + }; + + var colorRow = el("div", { className: "sx-edit-color-row" }); + for (var i = 0; i < colors.length; i++) { + (function (color) { + var swatch = el("button", { + type: "button", + className: "sx-edit-color-swatch" + (attrs.color === color ? " active" : ""), + style: "background:" + colorMap[color], + title: color + }); + swatch.addEventListener("click", function () { + attrs.color = color; + updateCardAttrs(wrapper, attrs); + // Update active state + var swatches = colorRow.querySelectorAll(".sx-edit-color-swatch"); + for (var j = 0; j < swatches.length; j++) swatches[j].classList.remove("active"); + swatch.classList.add("active"); + }); + colorRow.appendChild(swatch); + })(colors[i]); + } + panel.appendChild(colorRow); + + var emojiInput = el("input", { + type: "text", + className: "sx-edit-input sx-edit-emoji-input", + placeholder: "Emoji", + value: attrs.emoji || "", + maxlength: "4" + }); + emojiInput.addEventListener("input", function () { + attrs.emoji = emojiInput.value; + updateCardAttrs(wrapper, attrs); + }); + panel.appendChild(emojiInput); + + var contentArea = el("div", { + contenteditable: "true", + className: "sx-editable sx-edit-callout-content", + "data-placeholder": "Callout text..." + }); + contentArea.textContent = attrs.content || ""; + contentArea.addEventListener("input", function () { + attrs.content = contentArea.textContent; + updateCardAttrs(wrapper, attrs); + }); + panel.appendChild(contentArea); + } + + // -- Toggle card edit UI -- + function buildToggleEditUI(attrs, panel, wrapper) { + var headingInput = el("input", { + type: "text", + className: "sx-edit-input", + placeholder: "Toggle heading...", + value: attrs.heading || "" + }); + headingInput.addEventListener("input", function () { + attrs.heading = headingInput.value; + updateCardAttrs(wrapper, attrs); + }); + panel.appendChild(el("label", { className: "sx-edit-label" }, "Heading")); + panel.appendChild(headingInput); + + var contentArea = el("div", { + contenteditable: "true", + className: "sx-editable sx-edit-toggle-content", + "data-placeholder": "Toggle content..." + }); + contentArea.textContent = attrs.content || ""; + contentArea.addEventListener("input", function () { + attrs.content = contentArea.textContent; + updateCardAttrs(wrapper, attrs); + }); + panel.appendChild(el("label", { className: "sx-edit-label" }, "Content")); + panel.appendChild(contentArea); + } + + // -- Button card edit UI -- + function buildButtonEditUI(attrs, panel, wrapper) { + var controls = el("div", { className: "sx-edit-controls" }); + + controls.appendChild(makeInputRow("Button text", attrs.text || "", function (v) { + attrs.text = v; updateCardAttrs(wrapper, attrs); + })); + controls.appendChild(makeInputRow("Button URL", attrs.url || "", function (v) { + attrs.url = v; updateCardAttrs(wrapper, attrs); + })); + + var alignRow = makeSelectRow("Alignment", attrs.alignment || "center", [ + { value: "left", label: "Left" }, + { value: "center", label: "Center" }, + ], function (v) { + attrs.alignment = v; updateCardAttrs(wrapper, attrs); + }); + controls.appendChild(alignRow); + + panel.appendChild(controls); + } + + // -- Audio card edit UI -- + function buildAudioEditUI(attrs, panel, wrapper) { + if (attrs.src) { + var audio = el("audio", { src: attrs.src, controls: "true", className: "sx-edit-audio-player" }); + panel.appendChild(audio); + + var controls = el("div", { className: "sx-edit-controls" }); + controls.appendChild(makeInputRow("Title", attrs.title || "", function (v) { + attrs.title = v; updateCardAttrs(wrapper, attrs); + })); + + var replaceBtn = el("button", { + type: "button", className: "sx-edit-btn sx-edit-btn-sm" + }, "Replace audio"); + replaceBtn.addEventListener("click", function () { + triggerFileUpload(wrapper, "media", function (url) { + attrs.src = url; + updateCardAttrs(wrapper, attrs); + audio.src = url; + }); + }); + controls.appendChild(replaceBtn); + panel.appendChild(controls); + } else { + buildUploadArea(panel, wrapper, "media", "Drop audio file here or click to upload", function (url, file) { + attrs.src = url; + attrs.title = attrs.title || (file ? file.name.replace(/\.[^.]+$/, "") : ""); + updateCardAttrs(wrapper, attrs); + panel.innerHTML = ""; + buildAudioEditUI(attrs, panel, wrapper); + }); + } + } + + // -- Video card edit UI -- + function buildVideoEditUI(attrs, panel, wrapper) { + if (attrs.src) { + var video = el("video", { + src: attrs.src, controls: "true", className: "sx-edit-video-player" + }); + panel.appendChild(video); + + var controls = el("div", { className: "sx-edit-controls" }); + controls.appendChild(makeSelectRow("Width", attrs.width || "", [ + { value: "", label: "Normal" }, + { value: "wide", label: "Wide" }, + { value: "full", label: "Full" }, + ], function (v) { + attrs.width = v; updateCardAttrs(wrapper, attrs); + })); + + var loopLabel = el("label", { className: "sx-edit-checkbox-label" }); + var loopCheck = el("input", { type: "checkbox" }); + loopCheck.checked = !!attrs.loop; + loopCheck.addEventListener("change", function () { + attrs.loop = loopCheck.checked; + updateCardAttrs(wrapper, attrs); + }); + loopLabel.appendChild(loopCheck); + loopLabel.appendChild(document.createTextNode(" Loop")); + controls.appendChild(loopLabel); + + var replaceBtn = el("button", { + type: "button", className: "sx-edit-btn sx-edit-btn-sm" + }, "Replace video"); + replaceBtn.addEventListener("click", function () { + triggerFileUpload(wrapper, "media", function (url) { + attrs.src = url; + updateCardAttrs(wrapper, attrs); + video.src = url; + }); + }); + controls.appendChild(replaceBtn); + panel.appendChild(controls); + } else { + buildUploadArea(panel, wrapper, "media", "Drop video file here or click to upload", function (url) { + attrs.src = url; + updateCardAttrs(wrapper, attrs); + panel.innerHTML = ""; + buildVideoEditUI(attrs, panel, wrapper); + }); + } + } + + // -- File card edit UI -- + function buildFileEditUI(attrs, panel, wrapper) { + if (attrs.src) { + var controls = el("div", { className: "sx-edit-controls" }); + controls.appendChild(makeInputRow("Filename", attrs.filename || "", function (v) { + attrs.filename = v; updateCardAttrs(wrapper, attrs); + })); + controls.appendChild(makeInputRow("Title", attrs.title || "", function (v) { + attrs.title = v; updateCardAttrs(wrapper, attrs); + })); + if (attrs.filesize) { + controls.appendChild(el("div", { className: "sx-edit-info" }, "Size: " + attrs.filesize)); + } + + var replaceBtn = el("button", { + type: "button", className: "sx-edit-btn sx-edit-btn-sm" + }, "Replace file"); + replaceBtn.addEventListener("click", function () { + triggerFileUpload(wrapper, "file", function (url, file) { + attrs.src = url; + if (file) { + attrs.filename = file.name; + attrs.title = attrs.title || file.name; + attrs.filesize = formatFileSize(file.size); + } + updateCardAttrs(wrapper, attrs); + panel.innerHTML = ""; + buildFileEditUI(attrs, panel, wrapper); + }); + }); + controls.appendChild(replaceBtn); + panel.appendChild(controls); + } else { + buildUploadArea(panel, wrapper, "file", "Drop file here or click to upload", function (url, file) { + attrs.src = url; + if (file) { + attrs.filename = file.name; + attrs.title = file.name; + attrs.filesize = formatFileSize(file.size); + } + updateCardAttrs(wrapper, attrs); + panel.innerHTML = ""; + buildFileEditUI(attrs, panel, wrapper); + }); + } + } + + // -- Generic card edit UI (fallback) -- + function buildGenericEditUI(attrs, panel, wrapper) { + var controls = el("div", { className: "sx-edit-controls" }); + for (var k in attrs) { + (function (key) { + controls.appendChild(makeInputRow(key, String(attrs[key] || ""), function (v) { + attrs[key] = v; updateCardAttrs(wrapper, attrs); + })); + })(k); + } + panel.appendChild(controls); + } + + // ========================================================================= + // Shared edit UI helpers + // ========================================================================= + + function makeInputRow(label, value, onChange) { + var row = el("div", { className: "sx-edit-row" }); + var lbl = el("label", { className: "sx-edit-label" }, label); + var input = el("input", { + type: "text", className: "sx-edit-input", value: value + }); + input.addEventListener("input", function () { onChange(input.value); }); + row.appendChild(lbl); + row.appendChild(input); + return row; + } + + function makeSelectRow(label, value, options, onChange) { + var row = el("div", { className: "sx-edit-row" }); + var lbl = el("label", { className: "sx-edit-label" }, label); + var select = el("select", { className: "sx-edit-select" }); + for (var i = 0; i < options.length; i++) { + var opt = el("option", { value: options[i].value }, options[i].label); + if (options[i].value === value) opt.selected = true; + select.appendChild(opt); + } + select.addEventListener("change", function () { onChange(select.value); }); + row.appendChild(lbl); + row.appendChild(select); + return row; + } + + function buildUploadArea(panel, wrapper, uploadType, message, onUploaded) { + var area = el("div", { className: "sx-upload-area" }); + var icon = el("div", { className: "sx-upload-icon" }); + icon.innerHTML = ''; + var msg = el("div", { className: "sx-upload-msg" }, message); + var progress = el("div", { className: "sx-upload-progress", style: "display:none" }); + + area.appendChild(icon); + area.appendChild(msg); + area.appendChild(progress); + + // Click to upload + area.addEventListener("click", function () { + triggerFileUpload(wrapper, uploadType, onUploaded); + }); + + // Drag and drop + area.addEventListener("dragover", function (e) { + e.preventDefault(); + area.classList.add("sx-upload-dragover"); + }); + area.addEventListener("dragleave", function () { + area.classList.remove("sx-upload-dragover"); + }); + area.addEventListener("drop", function (e) { + e.preventDefault(); + area.classList.remove("sx-upload-dragover"); + var files = e.dataTransfer.files; + if (files.length) { + progress.style.display = "block"; + progress.textContent = "Uploading..."; + doUpload(wrapper, uploadType, files[0], function (url) { + onUploaded(url, files[0]); + }, function (err) { + progress.textContent = "Error: " + err; + setTimeout(function () { progress.style.display = "none"; }, 3000); + }); + } + }); + + panel.appendChild(area); + } + + // ========================================================================= + // File upload + // ========================================================================= + + function triggerFileUpload(wrapper, uploadType, onUploaded, multi) { + var input = document.createElement("input"); + input.type = "file"; + if (multi) input.multiple = true; + if (uploadType === "image") input.accept = "image/jpeg,image/png,image/gif,image/webp,image/svg+xml"; + else if (uploadType === "media") input.accept = "audio/*,video/*"; + + input.addEventListener("change", function () { + if (!input.files || !input.files.length) return; + for (var i = 0; i < input.files.length; i++) { + (function (file) { + doUpload(wrapper, uploadType, file, function (url) { + onUploaded(url, file); + }, function (err) { + console.error("Upload error:", err); + }); + })(input.files[i]); + } + }); + input.click(); + } + + function doUpload(wrapper, uploadType, file, onSuccess, onError) { + // Find the editor instance + var editor = findEditor(wrapper); + if (!editor || !editor._opts.uploadUrls) { + if (onError) onError("Upload not configured"); + return; + } + + var urlMap = { image: "image", media: "media", file: "file" }; + var url = editor._opts.uploadUrls[urlMap[uploadType] || uploadType]; + if (!url) { + if (onError) onError("Upload URL not configured for " + uploadType); + return; + } + + var fd = new FormData(); + fd.append("file", file); + + fetch(url, { + method: "POST", + body: fd, + headers: { "X-CSRFToken": editor._opts.csrfToken || "" } + }) + .then(function (r) { + if (!r.ok) throw new Error("Upload failed (" + r.status + ")"); + return r.json(); + }) + .then(function (data) { + // Ghost returns { images: [{url}] } or { media: [{url}] } or { files: [{url}] } + var resultUrl = null; + if (data.images && data.images[0]) resultUrl = data.images[0].url; + else if (data.media && data.media[0]) resultUrl = data.media[0].url; + else if (data.files && data.files[0]) resultUrl = data.files[0].url; + else if (data.url) resultUrl = data.url; + + if (!resultUrl) throw new Error("No URL in upload response"); + onSuccess(resultUrl); + fireChange(editor); + }) + .catch(function (e) { + if (onError) onError(e.message); + }); + } + + function findEditor(node) { + while (node) { + if (node._sxEditor) return node._sxEditor; + if (node.classList && node.classList.contains("sx-editor")) return node._sxEditor; + node = node.parentNode; + } + return null; + } + + // ========================================================================= + // oEmbed / bookmark metadata fetching + // ========================================================================= + + function fetchOembed(wrapper, url, attrs, panel, status) { + if (!url) { status.textContent = "Please enter a URL"; return; } + var editor = findEditor(wrapper); + if (!editor || !editor._opts.oembedUrl) { + status.textContent = "oEmbed not configured"; + return; + } + + status.textContent = "Fetching embed..."; + + fetch(editor._opts.oembedUrl + "?url=" + encodeURIComponent(url) + "&type=embed") + .then(function (r) { + if (!r.ok) throw new Error("oEmbed lookup failed (" + r.status + ")"); + return r.json(); + }) + .then(function (data) { + if (data.html) { + attrs.html = data.html; + attrs.url = url; + if (data.title) attrs.title = data.title; + updateCardAttrs(wrapper, attrs); + panel.innerHTML = ""; + buildEmbedEditUI(attrs, panel, wrapper); + fireChange(editor); + } else { + status.textContent = "No embed HTML returned. Try bookmark instead."; + } + }) + .catch(function (e) { + status.textContent = "Error: " + e.message; + }); + } + + function fetchBookmark(wrapper, url, attrs, panel, status) { + if (!url) { status.textContent = "Please enter a URL"; return; } + var editor = findEditor(wrapper); + if (!editor || !editor._opts.oembedUrl) { + status.textContent = "oEmbed not configured"; + return; + } + + status.textContent = "Fetching metadata..."; + + fetch(editor._opts.oembedUrl + "?url=" + encodeURIComponent(url) + "&type=bookmark") + .then(function (r) { + if (!r.ok) throw new Error("Metadata fetch failed (" + r.status + ")"); + return r.json(); + }) + .then(function (data) { + attrs.url = url; + attrs.title = data.metadata && data.metadata.title || data.title || ""; + attrs.description = data.metadata && data.metadata.description || data.description || ""; + attrs.author = data.metadata && data.metadata.author || data.author || ""; + attrs.publisher = data.metadata && data.metadata.publisher || data.publisher || ""; + attrs.thumbnail = data.metadata && data.metadata.thumbnail || data.thumbnail || ""; + attrs.icon = data.metadata && data.metadata.icon || data.icon || ""; + updateCardAttrs(wrapper, attrs); + panel.innerHTML = ""; + buildBookmarkEditUI(attrs, panel, wrapper); + fireChange(editor); + }) + .catch(function (e) { + status.textContent = "Error: " + e.message; + }); + } + + // ========================================================================= + // Plus button (Koenig-style: single floating + on empty paragraphs) + // ========================================================================= + + function createPlusButton(editor) { + var plusContainer = el("div", { className: "sx-plus-container" }); + plusContainer.style.display = "none"; + + var plusBtn = el("button", { + type: "button", className: "sx-plus-btn", title: "Add a card" + }); + plusBtn.innerHTML = ''; + + plusContainer.appendChild(plusBtn); + editor._root.appendChild(plusContainer); + + plusBtn.addEventListener("click", function (e) { + e.stopPropagation(); + if (editor._plusMenuOpen) { + closePlusMenu(editor); + } else { + showPlusMenu(editor, plusContainer); + } + }); + + return plusContainer; + } + + function positionPlusButton(editor) { + var plus = editor._plusContainer; + var block = editor._activeBlock; + + if (!block) { + plus.style.display = "none"; + return; + } + + var editable = block.querySelector("[contenteditable]"); + var isEmpty = editable && editable.textContent.trim() === "" && !editable.querySelector("img"); + var tag = block.getAttribute("data-sx-tag"); + var isTextBlock = TEXT_TAGS[tag]; + + if (!isTextBlock || !isEmpty) { + plus.style.display = "none"; + return; + } + + var containerRect = editor._root.getBoundingClientRect(); + var blockRect = block.getBoundingClientRect(); + + plus.style.display = "flex"; + plus.style.top = (blockRect.top - containerRect.top + (blockRect.height / 2) - 14) + "px"; + plus.style.left = "-40px"; + } + + function showPlusMenu(editor, plusContainer) { + closePlusMenu(editor); + + var menu = el("div", { className: "sx-plus-menu" }); + + for (var s = 0; s < CARD_MENU.length; s++) { + var section = CARD_MENU[s]; + var sectionEl = el("div", { className: "sx-plus-menu-section" }); + sectionEl.appendChild(el("div", { className: "sx-plus-menu-heading" }, section.section)); + + for (var j = 0; j < section.items.length; j++) { + (function (item) { + var row = el("button", { + type: "button", className: "sx-plus-menu-item" + }); + row.innerHTML = '' + + '' + escHtml(item.label) + '' + + '' + escHtml(item.desc) + ''; + row.addEventListener("click", function (e) { + e.stopPropagation(); + closePlusMenu(editor); + insertBlock(editor, item.type); + }); + sectionEl.appendChild(row); + })(section.items[j]); + } + menu.appendChild(sectionEl); + } + + // Text block types at the bottom + var textSection = el("div", { className: "sx-plus-menu-section" }); + textSection.appendChild(el("div", { className: "sx-plus-menu-heading" }, "Text")); + var textItems = [ + { type: "p", icon: "fa-solid fa-paragraph", label: "Paragraph" }, + { type: "h2", icon: "fa-solid fa-heading", label: "Heading 2" }, + { type: "h3", icon: "fa-solid fa-heading", label: "Heading 3" }, + { type: "blockquote", icon: "fa-solid fa-quote-left",label: "Quote" }, + { type: "ul", icon: "fa-solid fa-list-ul", label: "Bulleted List" }, + { type: "ol", icon: "fa-solid fa-list-ol", label: "Numbered List" }, + ]; + for (var t = 0; t < textItems.length; t++) { + (function (item) { + var row = el("button", { + type: "button", className: "sx-plus-menu-item" + }); + row.innerHTML = '' + + '' + escHtml(item.label) + ''; + row.addEventListener("click", function (e) { + e.stopPropagation(); + closePlusMenu(editor); + convertBlock(editor, item.type); + }); + textSection.appendChild(row); + })(textItems[t]); + } + menu.appendChild(textSection); + + plusContainer.appendChild(menu); + editor._plusMenuOpen = true; + + // Rotate the + to X + plusContainer.querySelector(".sx-plus-btn").classList.add("sx-plus-btn-open"); + + setTimeout(function () { + document.addEventListener("click", editor._closePlusHandler = function () { + closePlusMenu(editor); + }, { once: true }); + }, 0); + } + + function closePlusMenu(editor) { + if (editor._plusMenuOpen) { + var menu = editor._plusContainer.querySelector(".sx-plus-menu"); + if (menu) menu.remove(); + editor._plusMenuOpen = false; + var btn = editor._plusContainer.querySelector(".sx-plus-btn"); + if (btn) btn.classList.remove("sx-plus-btn-open"); + } + } + + // ========================================================================= + // Slash commands + // ========================================================================= + + function handleSlashCommand(editor, e) { + var editable = e.target; + if (!editable.hasAttribute || !editable.hasAttribute("contenteditable")) return; + var block = closestBlock(editable, editor._container); + if (!block) return; + + var text = editable.textContent; + + // If text starts with "/" show slash menu + if (text.charAt(0) === "/") { + var query = text.slice(1).toLowerCase(); + showSlashMenu(editor, block, editable, query); + } else { + closeSlashMenu(editor); + } + } + + function showSlashMenu(editor, block, editable, query) { + closeSlashMenu(editor); + + // Filter matching items + var matches = []; + for (var i = 0; i < ALL_CARD_ITEMS.length; i++) { + var item = ALL_CARD_ITEMS[i]; + if (!query) { + matches.push(item); + } else { + // Match against label and slash commands + var matchFound = item.label.toLowerCase().indexOf(query) !== -1; + if (!matchFound && item.slash) { + for (var s = 0; s < item.slash.length; s++) { + if (item.slash[s].indexOf(query) !== -1) { matchFound = true; break; } + } + } + if (matchFound) matches.push(item); + } + } + + if (matches.length === 0) return; + + var menu = el("div", { className: "sx-slash-menu" }); + + for (var i = 0; i < matches.length && i < 8; i++) { + (function (item) { + var row = el("button", { + type: "button", className: "sx-slash-menu-item" + }); + row.innerHTML = '' + + '' + escHtml(item.label) + '' + + '' + escHtml(item.desc) + ''; + row.addEventListener("mousedown", function (e) { + e.preventDefault(); + e.stopPropagation(); + closeSlashMenu(editor); + // Clear the slash text + editable.textContent = ""; + insertBlock(editor, item.type); + }); + menu.appendChild(row); + })(matches[i]); + } + + // Position below the block + var blockRect = block.getBoundingClientRect(); + var containerRect = editor._root.getBoundingClientRect(); + menu.style.top = (blockRect.bottom - containerRect.top + 4) + "px"; + menu.style.left = "0px"; + + editor._root.appendChild(menu); + editor._slashMenu = menu; + editor._slashBlock = block; + } + + function closeSlashMenu(editor) { + if (editor._slashMenu) { + editor._slashMenu.remove(); + editor._slashMenu = null; + editor._slashBlock = null; + } + } + + // ========================================================================= + // Block insertion & conversion + // ========================================================================= + + function insertBlock(editor, type) { + var block; + var container = editor._container; + var refBlock = editor._activeBlock; + + if (TEXT_TAGS[type]) { + block = createTextBlock(type, ""); + } else if (LIST_TAGS[type]) { + block = createListBlock(type, []); + } else if (type === "hr") { + block = createHrBlock(); + } else if (type === "code") { + block = createCodeBlock("", ""); + } else if (type === "image") { + // Create empty image card — edit mode opens automatically + block = createCardBlock("kg-image", {}); + insertBlockNode(editor, block, refBlock); + // Open edit mode immediately + block.querySelector(".sx-card-preview").click(); + return; + } else if (type === "gallery") { + block = createCardBlock("kg-gallery", {}); + insertBlockNode(editor, block, refBlock); + block.querySelector(".sx-card-preview").click(); + return; + } else if (type === "video") { + block = createCardBlock("kg-video", {}); + insertBlockNode(editor, block, refBlock); + block.querySelector(".sx-card-preview").click(); + return; + } else if (type === "audio") { + block = createCardBlock("kg-audio", {}); + insertBlockNode(editor, block, refBlock); + block.querySelector(".sx-card-preview").click(); + return; + } else if (type === "file") { + block = createCardBlock("kg-file", {}); + insertBlockNode(editor, block, refBlock); + block.querySelector(".sx-card-preview").click(); + return; + } else if (type === "callout") { + block = createCardBlock("kg-callout", { color: "grey", emoji: "", content: "" }); + insertBlockNode(editor, block, refBlock); + block.querySelector(".sx-card-preview").click(); + return; + } else if (type === "toggle") { + block = createCardBlock("kg-toggle", { heading: "", content: "" }); + insertBlockNode(editor, block, refBlock); + block.querySelector(".sx-card-preview").click(); + return; + } else if (type === "html") { + block = createCardBlock("kg-html", { html: "" }); + insertBlockNode(editor, block, refBlock); + block.querySelector(".sx-card-preview").click(); + return; + } else if (type === "button") { + block = createCardBlock("kg-button", { url: "", text: "Click here", alignment: "center" }); + insertBlockNode(editor, block, refBlock); + block.querySelector(".sx-card-preview").click(); + return; + } else if (type === "bookmark") { + block = createCardBlock("kg-bookmark", {}); + insertBlockNode(editor, block, refBlock); + block.querySelector(".sx-card-preview").click(); + return; + } else if (type === "embed") { + block = createCardBlock("kg-embed", {}); + insertBlockNode(editor, block, refBlock); + block.querySelector(".sx-card-preview").click(); + return; + } + + if (!block) return; + insertBlockNode(editor, block, refBlock); + + var editable = block.querySelector("[contenteditable]"); + if (editable) editable.focus(); + else { + var ta = block.querySelector("textarea"); + if (ta) ta.focus(); + } + } + + function insertBlockNode(editor, block, refBlock) { + var container = editor._container; + if (refBlock && refBlock.parentNode === container) { + refBlock.parentNode.insertBefore(block, refBlock.nextSibling); + } else { + container.appendChild(block); + } + editor._activeBlock = block; + fireChange(editor); + } + + function convertBlock(editor, newType) { + var block = editor._activeBlock; + if (!block) return; + + var tag = block.getAttribute("data-sx-tag"); + + // If same type, do nothing + if (tag === newType) return; + + // For text blocks, change the tag + if (TEXT_TAGS[tag] && TEXT_TAGS[newType]) { + var editable = block.querySelector("[contenteditable]"); + var html = editable ? editable.innerHTML : ""; + block.setAttribute("data-sx-tag", newType); + if (editable) { + editable.className = "sx-block-content sx-editable" + + (newType === "h2" || newType === "h3" ? " sx-heading" : "") + + (newType === "blockquote" ? " sx-quote" : ""); + editable.setAttribute("data-placeholder", + newType === "p" ? "Type / for commands..." : + newType === "blockquote" ? "Type a quote..." : + "Type a heading..."); + editable.setAttribute("data-block-type", newType); + editable.focus(); + } + fireChange(editor); + return; + } + + // For text→list conversion + if (TEXT_TAGS[tag] && LIST_TAGS[newType]) { + var editable = block.querySelector("[contenteditable]"); + var html = editable ? editable.innerHTML : ""; + var newBlock = createListBlock(newType, []); + var firstLi = newBlock.querySelector("[data-sx-li]"); + if (firstLi) firstLi.innerHTML = html; + block.parentNode.replaceChild(newBlock, block); + editor._activeBlock = newBlock; + var li = newBlock.querySelector("[contenteditable]"); + if (li) focusEnd(li); + fireChange(editor); + return; + } + + // For list→text conversion + if (LIST_TAGS[tag] && TEXT_TAGS[newType]) { + var firstLi = block.querySelector("[data-sx-li]"); + var html = firstLi ? firstLi.innerHTML : ""; + var newBlock = createTextBlock(newType, html); + block.parentNode.replaceChild(newBlock, block); + editor._activeBlock = newBlock; + var ed = newBlock.querySelector("[contenteditable]"); + if (ed) focusEnd(ed); + fireChange(editor); + return; + } + } + + // ========================================================================= + // Inline formatting toolbar + // ========================================================================= + + function createFormatBar() { + var bar = el("div", { className: "sx-format-bar" }); + bar.style.display = "none"; + + var commands = [ + { cmd: "bold", label: "B", cls: "sx-fmt-bold", shortcut: "Ctrl+B" }, + { cmd: "italic", label: "I", cls: "sx-fmt-italic", shortcut: "Ctrl+I" }, + { cmd: "h2", label: "H2", cls: "", shortcut: "Ctrl+Alt+2" }, + { cmd: "h3", label: "H3", cls: "", shortcut: "Ctrl+Alt+3" }, + { cmd: "blockquote", label: "❝", cls: "", shortcut: "" }, + { cmd: "link", label: '', cls: "", shortcut: "Ctrl+K" }, + ]; + + for (var i = 0; i < commands.length; i++) { + (function (c) { + var btn = el("button", { + type: "button", + className: "sx-format-btn" + (c.cls ? " " + c.cls : ""), + title: c.cmd + (c.shortcut ? " (" + c.shortcut + ")" : "") + }); + btn.innerHTML = c.label; + btn.addEventListener("mousedown", function (e) { + e.preventDefault(); + applyFormat(c.cmd); + }); + bar.appendChild(btn); + })(commands[i]); + } + + document.body.appendChild(bar); + return bar; + } + + function applyFormat(cmd) { + if (cmd === "link") { + var sel = window.getSelection(); + if (!sel.rangeCount) return; + // Check if already linked + var anchor = sel.anchorNode; + while (anchor && anchor.tagName !== "A") anchor = anchor.parentNode; + if (anchor && anchor.tagName === "A") { + document.execCommand("unlink", false, null); + } else { + var url = prompt("Link URL:"); + if (url) document.execCommand("createLink", false, url); + } + } else if (cmd === "h2" || cmd === "h3" || cmd === "blockquote") { + // Block-level format change: find the current block and convert it + var sel = window.getSelection(); + if (!sel.anchorNode) return; + var node = sel.anchorNode; + while (node && !(node.hasAttribute && node.hasAttribute("data-sx-block"))) { + node = node.parentNode; + } + if (!node) return; + var currentTag = node.getAttribute("data-sx-tag"); + // Toggle: if already this type, revert to p + var newType = currentTag === cmd ? "p" : cmd; + + var editable = node.querySelector("[contenteditable]"); + if (editable) { + node.setAttribute("data-sx-tag", newType); + editable.className = "sx-block-content sx-editable" + + (newType === "h2" || newType === "h3" ? " sx-heading" : "") + + (newType === "blockquote" ? " sx-quote" : ""); + editable.setAttribute("data-placeholder", + newType === "p" ? "Type / for commands..." : + newType === "blockquote" ? "Type a quote..." : + "Type a heading..."); + editable.setAttribute("data-block-type", newType); + } + } else if (cmd === "code") { + var sel = window.getSelection(); + if (sel.rangeCount) { + var range = sel.getRangeAt(0); + var code = document.createElement("code"); + try { + range.surroundContents(code); + } catch (ex) { + document.execCommand("insertHTML", false, "" + escHtml(sel.toString()) + ""); + } + } + } else { + document.execCommand(cmd, false, null); + } + } + + function updateFormatBar(editor) { + var bar = editor._formatBar; + var sel = window.getSelection(); + if (!sel.rangeCount || sel.isCollapsed) { + bar.style.display = "none"; + return; + } + + var anchor = sel.anchorNode; + var inEditor = false; + var node = anchor; + while (node) { + if (node === editor._container) { inEditor = true; break; } + node = node.parentNode; + } + if (!inEditor) { + bar.style.display = "none"; + return; + } + + var range = sel.getRangeAt(0); + var rect = range.getBoundingClientRect(); + if (rect.width === 0) { + bar.style.display = "none"; + return; + } + + bar.style.display = "flex"; + bar.style.top = (rect.top + window.scrollY - bar.offsetHeight - 8) + "px"; + bar.style.left = (rect.left + window.scrollX + rect.width / 2 - bar.offsetWidth / 2) + "px"; + } + + // ========================================================================= + // Keyboard handling + // ========================================================================= + + function handleKeydown(editor, e) { + var block = closestBlock(e.target, editor._container); + if (!block) return; + + var tag = block.getAttribute("data-sx-tag"); + + // Keyboard shortcuts + var mod = e.metaKey || e.ctrlKey; + if (mod && !e.shiftKey && !e.altKey) { + if (e.key === "b" || e.key === "B") { + e.preventDefault(); applyFormat("bold"); return; + } + if (e.key === "i" || e.key === "I") { + e.preventDefault(); applyFormat("italic"); return; + } + if (e.key === "k" || e.key === "K") { + e.preventDefault(); applyFormat("link"); return; + } + } + if (mod && e.altKey) { + if (e.key === "2") { e.preventDefault(); applyFormat("h2"); return; } + if (e.key === "3") { e.preventDefault(); applyFormat("h3"); return; } + } + + // Escape closes slash menu and card edit + if (e.key === "Escape") { + closeSlashMenu(editor); + closePlusMenu(editor); + // Exit card edit mode if in one + var editingCard = editor._container.querySelector(".sx-card-editing"); + if (editingCard) exitCardEdit(editingCard); + return; + } + + // Slash menu keyboard navigation + if (editor._slashMenu) { + if (e.key === "Escape") { + e.preventDefault(); + closeSlashMenu(editor); + return; + } + } + + // Enter in text block → new paragraph + if (e.key === "Enter" && !e.shiftKey) { + if (TEXT_TAGS[tag]) { + e.preventDefault(); + // Split at cursor or insert new p after + insertBlock(editor, "p"); + return; + } + // Enter in list item → new list item or exit list + if (LIST_TAGS[tag]) { + var li = e.target; + if (li.hasAttribute("data-sx-li") && li.textContent.trim() === "") { + // Empty list item → exit list, create new paragraph + e.preventDefault(); + if (block.querySelectorAll("[data-sx-li]").length <= 1) { + // Only one empty item → convert whole list to paragraph + var newBlock = createTextBlock("p", ""); + block.parentNode.replaceChild(newBlock, block); + editor._activeBlock = newBlock; + var ed = newBlock.querySelector("[contenteditable]"); + if (ed) ed.focus(); + } else { + // Remove this item and add paragraph after list + li.remove(); + var newBlock = createTextBlock("p", ""); + block.parentNode.insertBefore(newBlock, block.nextSibling); + editor._activeBlock = newBlock; + var ed = newBlock.querySelector("[contenteditable]"); + if (ed) ed.focus(); + } + fireChange(editor); + return; + } + if (li.hasAttribute("data-sx-li")) { + e.preventDefault(); + var newLi = el("div", { + contenteditable: "true", + className: "sx-list-item sx-editable", + "data-sx-li": "true", + "data-placeholder": "List item..." + }); + li.parentNode.insertBefore(newLi, li.nextSibling); + newLi.focus(); + fireChange(editor); + return; + } + } + } + + // Backspace in empty text block → delete block + if (e.key === "Backspace") { + var editable = e.target; + if (editable.hasAttribute("contenteditable") && + editable.textContent.trim() === "" && + !editable.querySelector("img")) { + + // In list, remove item + if (editable.hasAttribute("data-sx-li")) { + var items = block.querySelectorAll("[data-sx-li]"); + if (items.length > 1) { + e.preventDefault(); + var prevItem = editable.previousElementSibling; + editable.remove(); + if (prevItem) focusEnd(prevItem); + fireChange(editor); + return; + } + } + + var allBlocks = editor._container.querySelectorAll("[data-sx-block]"); + if (allBlocks.length > 1) { + e.preventDefault(); + removeBlock(editor, block); + return; + } + } + } + + // Arrow up at start of block → focus previous block + if (e.key === "ArrowUp") { + var editable = e.target; + if (editable.hasAttribute("contenteditable")) { + var sel = window.getSelection(); + if (sel.rangeCount) { + var range = sel.getRangeAt(0); + if (range.startOffset === 0 && range.collapsed) { + var prev = block.previousElementSibling; + while (prev && !prev.hasAttribute("data-sx-block")) prev = prev.previousElementSibling; + if (prev) { + e.preventDefault(); + var prevEditable = prev.querySelector("[contenteditable]:last-child") || + prev.querySelector("[contenteditable]"); + if (prevEditable) focusEnd(prevEditable); + editor._activeBlock = prev; + positionPlusButton(editor); + } + } + } + } + } + + // Arrow down at end of block → focus next block + if (e.key === "ArrowDown") { + var editable = e.target; + if (editable.hasAttribute("contenteditable")) { + var sel = window.getSelection(); + if (sel.rangeCount) { + var range = sel.getRangeAt(0); + var atEnd = range.collapsed && range.startContainer.nodeType === 3 && + range.startOffset === range.startContainer.textContent.length; + if (!atEnd) atEnd = range.collapsed && range.startOffset === editable.childNodes.length; + if (atEnd) { + var next = block.nextElementSibling; + while (next && !next.hasAttribute("data-sx-block")) next = next.nextElementSibling; + if (next) { + e.preventDefault(); + var nextEditable = next.querySelector("[contenteditable]"); + if (nextEditable) { + nextEditable.focus(); + var r = document.createRange(); + r.setStart(nextEditable, 0); + r.collapse(true); + sel.removeAllRanges(); + sel.addRange(r); + } + editor._activeBlock = next; + positionPlusButton(editor); + } + } + } + } + } + } + + function removeBlock(editor, block) { + var prev = block.previousElementSibling; + while (prev && !prev.hasAttribute("data-sx-block")) prev = prev.previousElementSibling; + block.remove(); + + if (prev) { + var editable = prev.querySelector("[contenteditable]:last-child") || + prev.querySelector("[contenteditable]"); + if (editable) focusEnd(editable); + editor._activeBlock = prev; + } else { + var first = editor._container.querySelector("[data-sx-block]"); + if (first) { + var editable = first.querySelector("[contenteditable]"); + if (editable) editable.focus(); + editor._activeBlock = first; + } + } + positionPlusButton(editor); + fireChange(editor); + } + + // ========================================================================= + // Change handler + // ========================================================================= + + function fireChange(editor) { + if (editor._changeTimeout) clearTimeout(editor._changeTimeout); + editor._changeTimeout = setTimeout(function () { + var sx = serializeBlocks(editor._container); + if (editor._opts.onChange) editor._opts.onChange(sx); + }, 150); + } + + // ========================================================================= + // Mount + // ========================================================================= + + function mount(elementId, opts) { + opts = opts || {}; + var root = document.getElementById(elementId); + if (!root) { + console.error("sx-editor: element not found: #" + elementId); + return null; + } + + root.className = (root.className || "") + " sx-editor"; + + var container = el("div", { className: "sx-blocks-container" }); + root.appendChild(container); + + var editor = { + _root: root, + _container: container, + _opts: opts, + _formatBar: createFormatBar(), + _plusContainer: null, + _plusMenuOpen: false, + _closePlusHandler: null, + _slashMenu: null, + _slashBlock: null, + _activeBlock: null, + _changeTimeout: null + }; + + // Store editor reference on root for findEditor() + root._sxEditor = editor; + + // Create floating + button + editor._plusContainer = createPlusButton(editor); + + // Load initial content + var blocks = []; + if (opts.initialSx) { + blocks = deserializeSx(opts.initialSx); + } + if (blocks.length === 0) { + blocks = [createTextBlock("p", "")]; + } + + for (var i = 0; i < blocks.length; i++) { + container.appendChild(blocks[i]); + } + + // Track active block + container.addEventListener("focusin", function (e) { + var block = closestBlock(e.target, container); + if (block) { + editor._activeBlock = block; + positionPlusButton(editor); + } + }); + + container.addEventListener("click", function (e) { + var block = closestBlock(e.target, container); + if (block) { + editor._activeBlock = block; + positionPlusButton(editor); + } + }); + + // Click on container background → focus last block or add new paragraph + container.addEventListener("click", function (e) { + if (e.target === container) { + // Clicked empty area below blocks → focus last block or create new one + var lastBlock = container.querySelector("[data-sx-block]:last-child"); + if (lastBlock) { + var editable = lastBlock.querySelector("[contenteditable]"); + if (editable && editable.textContent.trim() === "") { + editable.focus(); + } else { + // Add a new paragraph at the end + var newBlock = createTextBlock("p", ""); + container.appendChild(newBlock); + newBlock.querySelector("[contenteditable]").focus(); + editor._activeBlock = newBlock; + positionPlusButton(editor); + fireChange(editor); + } + } + } + }); + + // Event delegation + container.addEventListener("input", function (e) { + handleSlashCommand(editor, e); + fireChange(editor); + }); + + container.addEventListener("keydown", function (e) { + handleKeydown(editor, e); + }); + + // Click outside card → exit edit mode + document.addEventListener("click", function (e) { + var editingCards = editor._container.querySelectorAll(".sx-card-editing"); + for (var i = 0; i < editingCards.length; i++) { + if (!editingCards[i].contains(e.target)) { + exitCardEdit(editingCards[i]); + fireChange(editor); + } + } + }); + + // Delete card button + container.addEventListener("click", function (e) { + var deleteBtn = e.target.closest(".sx-card-tool-btn"); + if (deleteBtn) { + var block = closestBlock(deleteBtn, container); + if (block) removeBlock(editor, block); + } + }); + + document.addEventListener("selectionchange", function () { + updateFormatBar(editor); + }); + + // Drag-drop on the whole editor + container.addEventListener("dragover", function (e) { + e.preventDefault(); + container.classList.add("sx-drag-over"); + }); + container.addEventListener("dragleave", function (e) { + if (!container.contains(e.relatedTarget)) { + container.classList.remove("sx-drag-over"); + } + }); + container.addEventListener("drop", function (e) { + container.classList.remove("sx-drag-over"); + var files = e.dataTransfer && e.dataTransfer.files; + if (!files || !files.length) return; + + e.preventDefault(); + + // Find drop position + var dropBlock = closestBlock(e.target, container); + editor._activeBlock = dropBlock || container.querySelector("[data-sx-block]:last-child"); + + for (var i = 0; i < files.length; i++) { + (function (file) { + var type = file.type; + if (type.startsWith("image/")) { + var block = createCardBlock("kg-image", {}); + insertBlockNode(editor, block, editor._activeBlock); + doUpload(block, "image", file, function (url) { + var attrs = { src: url, alt: "" }; + updateCardAttrs(block, attrs); + block.setAttribute("data-sx-attrs", JSON.stringify(attrs)); + renderCardPreview("kg-image", attrs, block.querySelector(".sx-card-preview")); + fireChange(editor); + }, function (err) { + console.error("Upload error:", err); + }); + } else if (type.startsWith("video/")) { + var block = createCardBlock("kg-video", {}); + insertBlockNode(editor, block, editor._activeBlock); + doUpload(block, "media", file, function (url) { + var attrs = { src: url }; + updateCardAttrs(block, attrs); + block.setAttribute("data-sx-attrs", JSON.stringify(attrs)); + renderCardPreview("kg-video", attrs, block.querySelector(".sx-card-preview")); + fireChange(editor); + }, function (err) { + console.error("Upload error:", err); + }); + } else if (type.startsWith("audio/")) { + var block = createCardBlock("kg-audio", {}); + insertBlockNode(editor, block, editor._activeBlock); + doUpload(block, "media", file, function (url) { + var attrs = { src: url, title: file.name.replace(/\.[^.]+$/, "") }; + updateCardAttrs(block, attrs); + block.setAttribute("data-sx-attrs", JSON.stringify(attrs)); + renderCardPreview("kg-audio", attrs, block.querySelector(".sx-card-preview")); + fireChange(editor); + }, function (err) { + console.error("Upload error:", err); + }); + } else { + var block = createCardBlock("kg-file", {}); + insertBlockNode(editor, block, editor._activeBlock); + doUpload(block, "file", file, function (url) { + var attrs = { src: url, filename: file.name, title: file.name, filesize: formatFileSize(file.size) }; + updateCardAttrs(block, attrs); + block.setAttribute("data-sx-attrs", JSON.stringify(attrs)); + renderCardPreview("kg-file", attrs, block.querySelector(".sx-card-preview")); + fireChange(editor); + }, function (err) { + console.error("Upload error:", err); + }); + } + })(files[i]); + } + }); + + // Set min-height and cursor for clicking below content + container.style.minHeight = "300px"; + container.style.cursor = "text"; + + // Focus first editable + var firstEditable = container.querySelector("[contenteditable]"); + if (firstEditable) firstEditable.focus(); + editor._activeBlock = container.querySelector("[data-sx-block]"); + positionPlusButton(editor); + + return { + getSx: function () { + return serializeBlocks(container); + }, + destroy: function () { + if (editor._formatBar) editor._formatBar.remove(); + if (editor._plusContainer) editor._plusContainer.remove(); + root._sxEditor = null; + root.innerHTML = ""; + root.className = root.className.replace(/\bsx-editor\b/, "").trim(); + } + }; + } + + // ========================================================================= + // Export + // ========================================================================= + + window.SxEditor = { + mount: mount, + _serializeBlocks: serializeBlocks, + _serializeInline: serializeInline, + _deserializeSx: deserializeSx + }; + +})();