diff --git a/.gitignore b/.gitignore index b7faf40..c74ef74 100644 --- a/.gitignore +++ b/.gitignore @@ -1,207 +1,157 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[codz] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintainted in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ +*.pyc + +# Ignore Windows ADS quarantine metadata files +*:Zone.Identifier +*:zone.identifier diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d23c5f --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# ComfyUI Content Viewer + +A versatile ComfyUI custom node that renders HTML, SVG, Markdown, and code content in a secure embedded iframe viewer with full editing, download, and list support. + +![Content Viewer](https://img.shields.io/badge/ComfyUI-Content_Viewer-blue) +![License](https://img.shields.io/badge/License-MIT-green) + +## Features + +### Content Rendering +- **Auto-detection** of content type: HTML, SVG, Markdown, Python, JavaScript, CSS, or plain text +- **SVG support** - Renders SVG graphics centered in the viewport +- **Markdown parsing** - Full support for headers, lists, links, images, code blocks, tables, and emphasis +- **Code formatting** - Code content displayed with monospace font in styled blocks +- **ComfyUI theme integration** - Automatically matches ComfyUI's theme colors + +### List Support +- **Multiple items** - Displays list inputs as individual indexed containers +- **Per-item copy buttons** - Copy individual list items to clipboard +- **Per-item editing** - Edit each list item in its own textarea +- **List separator** - Items joined/split using `---LIST_SEPARATOR---` + +### Controls +- **Edit** - Modify content directly in the node (supports both single and list content) +- **Clear** - Reset all content +- **Fullscreen** - Expand viewer to full screen (press Escape to exit) +- **Download** - Save content with auto-detected file extension + - Single content → Downloads as `.html`, `.svg`, `.md`, `.py`, `.js`, `.css`, or `.txt` + - List content → Downloads as `.zip` containing individually named files (`item_001.txt`, `item_002.txt`, etc.) + +### Data Flow +- **STRING output** - Passes content downstream for further processing +- **Flexible input** - Accepts any type via `content` input (STRING, lists, objects) +- **Content priority** - User edits > Backend execution > Dynamic connected content +- **Workflow persistence** - Manual edits saved with the workflow + +### Security +- **Sandboxed iframe** - Content runs in isolated sandbox without access to parent page +- **No same-origin** - Iframe cannot access ComfyUI's JavaScript context or localStorage + +## Installation + +### Via ComfyUI Manager +Search for "Content Viewer" in ComfyUI Manager and install. + +### Manual Installation +Clone this repository into your ComfyUI `custom_nodes` folder: + +```bash +cd ComfyUI/custom_nodes +git clone https://github.com/WASasquatch/ComfyUI_Viewer +``` + +Restart ComfyUI. + +## Usage + +### Basic Usage +1. Add the **Content Viewer** node (found in `WAS/View` category) +2. Connect any STRING output to the `content` input +3. Content will be auto-detected and rendered + +### As a Notes/Documentation Node +1. Add the **Content Viewer** node +2. Click **Edit** in the controls bar +3. Enter your HTML, Markdown, or text content +4. Click **Save** to render + +### Working with Lists +When connecting a node that outputs multiple strings: +1. Each string displays in its own container with an index label +2. Click the copy button on any item to copy just that content +3. Click **Edit** to modify individual items in separate textareas +4. Click **Download** to get a `.zip` file with each item as a separate file + +## Supported Content Types + +| Type | Detection | File Extension | +|------|-----------|----------------| +| **HTML** | ` bool: + return False + + +any_type = AnyType("*") + + +class WASComfyViewer: + @classmethod + def INPUT_TYPES(cls): + return { + "required": {}, + "optional": { + "content": (any_type, {"forceInput": True}), + }, + "hidden": { + "manual_content": ("STRING", {"default": ""}), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("content",) + OUTPUT_NODE = True + INPUT_IS_LIST = True + OUTPUT_IS_LIST = (True,) + FUNCTION = "run" + CATEGORY = "WAS/View" + + def run(self, content=None, manual_content=None): + import json + import logging + + logger = logging.getLogger("WAS.ContentViewer") + + def to_string(item): + if item is None: + return "" + if isinstance(item, str): + return item + if isinstance(item, (int, float, bool)): + return str(item) + try: + return json.dumps(item) + except Exception as e: + logger.warning(f"[WAS Viewer] JSON serialization failed: {e}") + try: + return str(item) + except Exception as e2: + logger.error(f"[WAS Viewer] String conversion failed: {e2}") + return "Content exists but could not be serialized." + + if content is None: + content = [] + if not isinstance(content, list): + content = [content] + + if manual_content is None: + manual_content = [] + if not isinstance(manual_content, list): + manual_content = [manual_content] + + content_trimmed = [c[:256] for c in content] + manual_content_trimmed = [c[:256] for c in manual_content] + + logger.info(f"\n[WAS Viewer] Content:\n{content_trimmed}\nManual Content:\n{manual_content_trimmed}\n") + + if len(content) > 0 and any(c for c in content): + values = [to_string(c) for c in content] + logger.info(f"[WAS Viewer] Using content input: {len(values)} items") + elif len(manual_content) > 0 and any(m for m in manual_content): + values = [to_string(m) for m in manual_content] + logger.info(f"[WAS Viewer] Using manual_content: {len(values)} items") + else: + values = [""] + logger.info("[WAS Viewer] No content, using empty") + + display_text = "\n---LIST_SEPARATOR---\n".join(values) + return {"ui": {"text": (display_text,)}, "result": (values,)} + + +NODE_CLASS_MAPPINGS = { + "WASComfyViewer": WASComfyViewer, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "WASComfyViewer": "Content Viewer", +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c7ffa04 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "ComfyUI_Viewer" +version = "0.0.1" +description = "ComfyUI Content Viewer - Renders HTML, Markdown, and code in an iframe" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT License" } +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent" +] +dynamic = ["dependencies"] + +[tool.setuptools.dynamic] +dependencies = { file = ["requirements.txt"] } + +[project.urls] +Repository = "https://github.com/WASasquatch/ComfyUI_Viewer" +"Bug Tracker" = "https://github.com/WASasquatch/ComfyUI_Viewer/issues" + +[tool.comfy] +PublisherId = "was" +DisplayName = "ComfyUI Content Viewer" +requires-comfyui = ">=1.0.0" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..61ab157 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +# No additional dependencies required diff --git a/web/comfy_viewer.js b/web/comfy_viewer.js new file mode 100644 index 0000000..d411bfe --- /dev/null +++ b/web/comfy_viewer.js @@ -0,0 +1,1368 @@ +import { app } from "../../scripts/app.js"; + +const EXT_NAME = "WAS.ContentViewer"; +const NODE_NAME = "WASComfyViewer"; + +const DEFAULT_NODE_SIZE = [600, 500]; +const CONTROLS_HEIGHT = 32; +const INPUT_SLOT_HEIGHT = 26; + +const STATE = { + container: null, + nodeIdToElements: new Map(), + cleanupIntervalId: null, + cleanupListenersAttached: false, + lastScale: 1, +}; + +function readCssVar(style, name) { + const v = style.getPropertyValue(name); + return v ? String(v).trim() : ""; +} + +function computeThemeTokens() { + const rootStyle = getComputedStyle(document.documentElement); + const bodyStyle = getComputedStyle(document.body); + + const bg = + readCssVar(rootStyle, "--comfy-menu-bg") || + readCssVar(rootStyle, "--background-color") || + readCssVar(rootStyle, "--bg-color") || + bodyStyle.backgroundColor || + "#1a1a1a"; + const fg = + readCssVar(rootStyle, "--input-text") || + readCssVar(rootStyle, "--text-color") || + readCssVar(rootStyle, "--fg-color") || + bodyStyle.color || + "#e0e0e0"; + const border = + readCssVar(rootStyle, "--border-color") || + readCssVar(rootStyle, "--comfy-border-color") || + "#444"; + const accent = + readCssVar(rootStyle, "--primary-color") || + readCssVar(rootStyle, "--accent-color") || + readCssVar(rootStyle, "--comfy-accent") || + "#4a9eff"; + + return { bg, fg, border, accent }; +} + +function detectContentType(content) { + if (!content || typeof content !== "string") return "text"; + const trimmed = content.trim(); + + if (trimmed.startsWith("") && + (trimmed.includes("") || + trimmed.includes("/m, + /\*\*.+?\*\*/, + /__.+?__/, + /~~.+?~~/, + /^\s*\|.+\|/m, + /!\[.+?\]\(.+?\)/, + ]; + + let mdScore = 0; + for (const pattern of mdPatterns) { + if (pattern.test(trimmed)) mdScore++; + } + if (mdScore >= 1) return "markdown"; + + const firstLine = trimmed.split("\n")[0].toLowerCase(); + + if (firstLine.includes("import ") || firstLine.includes("from ") || + firstLine.startsWith("def ") || firstLine.startsWith("class ") || + trimmed.includes("def ") || trimmed.includes("self.")) { + return "python"; + } + + if (firstLine.includes("function ") || firstLine.includes("const ") || + firstLine.includes("let ") || firstLine.includes("var ") || + trimmed.includes("=> {") || trimmed.includes("function(") || + firstLine.startsWith("import {") || firstLine.startsWith("export ")) { + return "javascript"; + } + + if (trimmed.includes("{") && trimmed.includes("}") && + (trimmed.includes(":") || trimmed.includes(";"))) { + const cssPatterns = [ + /[.#]?[\w-]+\s*\{[^}]*\}/, + /:\s*[\w-]+\s*;/, + /@media\s/, + /@import\s/, + /@keyframes\s/, + ]; + let cssScore = 0; + for (const pattern of cssPatterns) { + if (pattern.test(trimmed)) cssScore++; + } + if (cssScore >= 2) return "css"; + } + + return "text"; +} + +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +function parseMarkdown(md) { + let html = md; + + html = html.replace(/^```(\w*)\n([\s\S]*?)```$/gm, (_, lang, code) => { + return `
${escapeHtml(code.trim())}
`; + }); + + html = html.replace(/`([^`]+)`/g, "$1"); + + html = html.replace(/^######\s+(.*)$/gm, "
$1
"); + html = html.replace(/^#####\s+(.*)$/gm, "
$1
"); + html = html.replace(/^####\s+(.*)$/gm, "

$1

"); + html = html.replace(/^###\s+(.*)$/gm, "

$1

"); + html = html.replace(/^##\s+(.*)$/gm, "

$1

"); + html = html.replace(/^#\s+(.*)$/gm, "

$1

"); + + html = html.replace(/\*\*(.+?)\*\*/g, "$1"); + html = html.replace(/__(.+?)__/g, "$1"); + html = html.replace(/\*(.+?)\*/g, "$1"); + html = html.replace(/_([^_]+)_/g, "$1"); + html = html.replace(/~~(.+?)~~/g, "$1"); + + html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + html = html.replace(/^>\s+(.*)$/gm, "
$1
"); + + html = html.replace(/^\d+\.\s+(.*)$/gm, "$1"); + html = html.replace(/(.*<\/oli>\n?)+/g, (match) => { + return "
    " + match.replace(/<\/?oli>/g, (tag) => tag.replace("oli", "li")) + "
"; + }); + + html = html.replace(/^[-*+]\s+(.*)$/gm, "$1"); + html = html.replace(/(.*<\/uli>\n?)+/g, (match) => { + return "
    " + match.replace(/<\/?uli>/g, (tag) => tag.replace("uli", "li")) + "
"; + }); + + html = html.replace(/^---$/gm, "
"); + html = html.replace(/^\*\*\*$/gm, "
"); + + html = html.replace(/\n\n/g, "

"); + html = "

" + html + "

"; + html = html.replace(/

\s*<(h[1-6]|ul|ol|pre|blockquote|hr)/g, "<$1"); + html = html.replace(/<\/(h[1-6]|ul|ol|pre|blockquote)>\s*<\/p>/g, ""); + html = html.replace(/

\s*<\/p>/g, ""); + + return html; +} + +function renderCodeContent(content, language) { + const langMap = { + python: "python", + javascript: "javascript", + css: "css", + text: "plaintext", + }; + const lang = langMap[language] || "plaintext"; + return `

${escapeHtml(content)}
`; +} + +async function createZipBlob(files) { + const crc32 = (data) => { + let crc = 0xFFFFFFFF; + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); + table[i] = c; + } + for (let i = 0; i < data.length; i++) crc = table[(crc ^ data[i]) & 0xFF] ^ (crc >>> 8); + return (crc ^ 0xFFFFFFFF) >>> 0; + }; + + const encoder = new TextEncoder(); + const parts = []; + const centralDir = []; + let offset = 0; + + for (const file of files) { + const nameBytes = encoder.encode(file.name); + const contentBytes = encoder.encode(file.content); + const crc = crc32(contentBytes); + const localHeader = new Uint8Array(30 + nameBytes.length); + const view = new DataView(localHeader.buffer); + + view.setUint32(0, 0x04034B50, true); + view.setUint16(4, 20, true); + view.setUint16(6, 0, true); + view.setUint16(8, 0, true); + view.setUint16(10, 0, true); + view.setUint16(12, 0, true); + view.setUint32(14, crc, true); + view.setUint32(18, contentBytes.length, true); + view.setUint32(22, contentBytes.length, true); + view.setUint16(26, nameBytes.length, true); + view.setUint16(28, 0, true); + localHeader.set(nameBytes, 30); + + const centralEntry = new Uint8Array(46 + nameBytes.length); + const cView = new DataView(centralEntry.buffer); + cView.setUint32(0, 0x02014B50, true); + cView.setUint16(4, 20, true); + cView.setUint16(6, 20, true); + cView.setUint16(8, 0, true); + cView.setUint16(10, 0, true); + cView.setUint16(12, 0, true); + cView.setUint16(14, 0, true); + cView.setUint32(16, crc, true); + cView.setUint32(20, contentBytes.length, true); + cView.setUint32(24, contentBytes.length, true); + cView.setUint16(28, nameBytes.length, true); + cView.setUint16(30, 0, true); + cView.setUint16(32, 0, true); + cView.setUint16(34, 0, true); + cView.setUint16(36, 0, true); + cView.setUint32(38, 0, true); + cView.setUint32(42, offset, true); + centralEntry.set(nameBytes, 46); + + parts.push(localHeader, contentBytes); + centralDir.push(centralEntry); + offset += localHeader.length + contentBytes.length; + } + + const centralDirOffset = offset; + let centralDirSize = 0; + for (const entry of centralDir) { + parts.push(entry); + centralDirSize += entry.length; + } + + const endRecord = new Uint8Array(22); + const endView = new DataView(endRecord.buffer); + endView.setUint32(0, 0x06054B50, true); + endView.setUint16(4, 0, true); + endView.setUint16(6, 0, true); + endView.setUint16(8, files.length, true); + endView.setUint16(10, files.length, true); + endView.setUint32(12, centralDirSize, true); + endView.setUint32(16, centralDirOffset, true); + endView.setUint16(20, 0, true); + parts.push(endRecord); + + return new Blob(parts, { type: "application/zip" }); +} + +function buildIframeContent(content, contentType, theme) { + const baseStyles = ` + * { box-sizing: border-box; } + html, body { + margin: 0; + padding: 4px; + background: ${theme.bg}; + color: ${theme.fg}; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.6; + overflow-x: hidden; + word-wrap: break-word; + } + a { color: ${theme.accent}; } + pre { + background: rgba(0,0,0,0.3); + padding: 12px; + border-radius: 6px; + overflow-x: hidden; + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + font-family: "Fira Code", "Consolas", "Monaco", monospace; + font-size: 13px; + } + code { + background: rgba(0,0,0,0.2); + padding: 2px 6px; + border-radius: 4px; + font-family: "Fira Code", "Consolas", "Monaco", monospace; + } + pre code { + background: transparent; + padding: 0; + } + blockquote { + border-left: 4px solid ${theme.accent}; + margin: 12px 0; + padding: 8px 16px; + background: rgba(0,0,0,0.15); + } + img { max-width: 100%; height: auto; border-radius: 4px; } + table { + border-collapse: collapse; + width: 100%; + margin: 12px 0; + } + th, td { + border: 1px solid ${theme.border}; + padding: 8px; + text-align: left; + } + th { background: rgba(0,0,0,0.2); } + hr { + border: none; + border-top: 1px solid ${theme.border}; + margin: 16px 0; + } + h1, h2, h3, h4, h5, h6 { + margin-top: 20px; + margin-bottom: 10px; + color: ${theme.fg}; + } + ul, ol { padding-left: 24px; } + li { margin: 4px 0; } + .list-item { + background: rgba(0,0,0,0.2); + border: 1px solid ${theme.border}; + border-radius: 6px; + padding: 12px; + margin-bottom: 8px; + position: relative; + } + .list-item:last-child { margin-bottom: 0; } + .list-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + } + .list-index { + display: inline-block; + background: ${theme.accent}; + color: #fff; + font-size: 11px; + font-weight: bold; + padding: 2px 6px; + border-radius: 4px; + } + .copy-btn { + background: transparent; + border: 1px solid ${theme.border}; + border-radius: 4px; + color: ${theme.fg}; + cursor: pointer; + padding: 4px 8px; + font-size: 11px; + display: flex; + align-items: center; + gap: 4px; + transition: all 0.2s; + } + .copy-btn:hover { + background: ${theme.accent}; + color: #fff; + border-color: ${theme.accent}; + } + .copy-btn.copied { + background: #22c55e; + border-color: #22c55e; + color: #fff; + } + .list-content { + white-space: pre-wrap; + word-wrap: break-word; + } + `; + + const LIST_SEPARATOR = "\n---LIST_SEPARATOR---\n"; + const isListContent = content.includes(LIST_SEPARATOR); + + let bodyContent = ""; + + if (isListContent) { + const items = content.split(LIST_SEPARATOR); + const itemsData = JSON.stringify(items); + bodyContent = items.map((item, idx) => { + const itemType = detectContentType(item); + let itemHtml = ""; + if (itemType === "html") { + itemHtml = item; + } else if (itemType === "markdown") { + itemHtml = parseMarkdown(item); + } else { + itemHtml = `
${escapeHtml(item)}
`; + } + return `
+
+ ${idx + 1} / ${items.length} + +
+ ${itemHtml} +
`; + }).join(""); + bodyContent += `