Refact for 0.2.0; Modular View Extension System; Canvas View; Object View; Numerous optimizations

This commit is contained in:
Jordan Thompson
2026-01-03 16:13:12 -08:00
parent a4bf97d21f
commit 63294faedf
66 changed files with 10707 additions and 1622 deletions
+6
View File
@@ -155,3 +155,9 @@ cython_debug/
# Ignore Windows ADS quarantine metadata files
*:Zone.Identifier
*:zone.identifier
# Ruff
.ruff_cache
# WAS Architect
.was_arch
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 WAS
Copyright (c) 2025 Jordan "WASasquatch" Thompson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+393 -70
View File
@@ -3,7 +3,7 @@
![Content Viewer](https://img.shields.io/badge/ComfyUI-Custom_Node-blue)
![License](https://img.shields.io/badge/License-MIT-green)
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.
A powerful, extensible ComfyUI custom node with a modular **Views Extension System** that renders content in a secure embedded iframe viewer. Views are dynamically loaded and can be added, removed, or modified without touching core code.
<details>
<summary><strong>Screenshots</strong></summary>
@@ -13,72 +13,157 @@ A versatile ComfyUI custom node that renders HTML, SVG, Markdown, and code conte
![Screenshot 3](screenshots/screenshot_3.jpg)
![Screenshot 4](screenshots/screenshot_4.jpg)
![Screenshot 5](screenshots/screenshot_5.jpg)
![Screenshot 5](screenshots/screenshot_6.jpg)
![Screenshot 4](screenshots/screenshot_7.jpg)
![Screenshot 5](screenshots/screenshot_8.jpg)
![Screenshot 5](screenshots/screenshot_9.jpg)
![Screenshot 6](screenshots/screenshot_6.jpg)
![Screenshot 7](screenshots/screenshot_7.jpg)
![Screenshot 8](screenshots/screenshot_8.jpg)
![Screenshot 9](screenshots/screenshot_9.jpg)
![Screenshot 6](screenshots/screenshot_10.jpg)
![Screenshot 7](screenshots/screenshot_11.jpg)
![Screenshot 8](screenshots/screenshot_12.jpg)
![Screenshot 9](screenshots/screenshot_13.jpg)
![Screenshot 9](screenshots/screenshot_14.jpg)
</details>
## Features
### Content Rendering
- **Auto-detection** of content type: HTML, SVG, Markdown, JSON, YAML, CSV, Python, JavaScript, CSS, Terminal (ANSI), or plain text
- **SVG support** - Renders SVG graphics centered in the viewport
- **Markdown parsing** - Full support for headers, lists, links, images, code blocks, tables, emphasis, and Mermaid diagrams
- **JSON viewer** - Collapsible tree view with syntax highlighting
- **CSV/Tables** - Automatic table rendering with styled headers
- **YAML** - Syntax highlighted display
- **LaTeX/Math** - Renders mathematical formulas using KaTeX (`$$...$$` or `$...$`)
- **Terminal output** - Parses ANSI escape codes for colored terminal logs
- **Code formatting** - Code content displayed with monospace font and Prism.js syntax highlighting
- **ComfyUI theme integration** - Automatically matches ComfyUI's theme colors
### Views Extension System
- **Modular architecture** - Each content type is handled by its own view extension
- **Dynamic loading** - Views are auto-discovered from `web/views/view_manifest.js`
- **Priority-based detection** - Higher priority views are checked first for content matching
- **Extensible** - Add custom views without modifying core viewer code
- **Theme integration** - All views receive ComfyUI theme tokens for consistent styling
### List Support
- **Multiple items** - Displays list inputs as individual indexed containers, filter what items to output.
- **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---`
### Multi-View Support
- **Automatic detection** - When multiple views can handle the same content, a view selector appears
- **View switching** - Toggle between different visualizations of the same data (e.g., tensor as Canvas vs Object inspector)
- **Priority-based default** - The highest priority matching view is shown by default
- **Common use case** - IMAGE tensors are detected by both Canvas (for compositing) and Object (for metrics/inspection) views
### 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.)
- Single content → Downloads with appropriate extension (`.html`, `.svg`, `.md`, `.py`, etc.)
- List content → Downloads as `.zip` containing individually named files
### String List Support
- **Multiple items** - Displays list inputs as individual indexed containers
- **Per-item checkboxes** - Filter which items to include in output
- **Per-item copy buttons** - Copy individual list items to clipboard
- **Per-item editing** - Edit each list item in its own textarea
### 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
- **Flexible input** - Accepts any type via `content` input (STRING, lists, objects, IMAGE tensors)
- **Content priority** - User edits > Backend execution > 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
---
## Built-in Views
The Content Viewer includes 12 built-in views, listed by detection priority:
| View | Priority | Detection | Description |
|------|----------|-----------|-------------|
| **SVG** | 110 | `<svg` tag with xmlns | Renders SVG graphics centered in viewport |
| **HTML** | 100 | `<!DOCTYPE`, `<html`, common HTML tags | Full HTML rendering with scripts |
| **Canvas** (alpha wip) | 95 | JSON with `type: "canvas_composer"` | Beta implementation of a infinite canvas image compositor with layers |
| **JSON** | 90 | Valid JSON starting with `{` or `[` | Collapsible tree view with syntax highlighting |
| **Terminal** | 80 | ANSI escape codes (`\x1b[`, `\033[`) | Colored terminal output parser |
| **Markdown** | 70 | Headers, lists, links, code blocks | Full MD with Mermaid diagrams & KaTeX math |
| **Python** | 60 | `import`, `def`, `class`, `self.` | Prism.js syntax highlighting |
| **JavaScript** | 55 | `function`, `const`, `let`, arrow functions | Prism.js syntax highlighting |
| **CSV** | 50 | Comma-separated values across lines | Styled table rendering |
| **CSS** | 45 | Selectors with `{}`, properties | Prism.js syntax highlighting |
| **YAML** | 40 | Key-value pairs, `---` markers | Syntax highlighted display |
| **Text** | 0 | Default fallback | Monospace plain text |
### View Details
<details>
<summary><strong>Canvas View</strong> - Infinite canvas image compositor</summary>
The Canvas view provides a full-featured image compositing workspace:
- **Layer management** - Add, reorder, hide/show, delete layers
- **Transform tools** - Move, scale, rotate layers with handles
- **Brush tools** - Paint, erase, clone stamp with adjustable size/hardness/opacity
- **Layer effects** - Opacity, blend modes (16 modes), stroke, shadow
- **Layer masks** - Non-destructive masking with brush tools
- **Export** - Download PNG or send composite to node output as IMAGE tensor
- **Navigator** - Mini-map for large canvas navigation
- **Keyboard shortcuts** - V (select), H (pan), B (brush), Delete, Ctrl+Z/Y
The Canvas view outputs `IMAGE` and `MASK` tensors that can be connected to other ComfyUI nodes.
</details>
<details>
<summary><strong>Markdown View</strong> - Rich text with diagrams and math</summary>
Supports full Markdown syntax plus:
- **Mermaid diagrams** - Flowcharts, sequence diagrams, etc. in ` ```mermaid ` blocks
- **KaTeX math** - LaTeX formulas via `$$...$$` (block) or `$...$` (inline)
- **Tables** - Styled with zebra striping
- **Code blocks** - Syntax highlighted with Prism.js
- **Task lists** - Checkbox items `- [x]` and `- [ ]`
</details>
<details>
<summary><strong>JSON View</strong> - Interactive tree viewer</summary>
- **Collapsible nodes** - Click to expand/collapse objects and arrays
- **Syntax highlighting** - Keys, strings, numbers, booleans, null
- **Copy on click** - Click any value to copy it
- **Nested formatting** - Proper indentation for deep structures
</details>
<details>
<summary><strong>Terminal View</strong> - ANSI escape code parser</summary>
Renders terminal output with full ANSI support:
- **16 colors** - Standard terminal colors
- **256 colors** - Extended color palette
- **24-bit colors** - True color RGB
- **Styles** - Bold, italic, underline, strikethrough
- **Reset codes** - Proper handling of reset sequences
</details>
---
## Installation
- **Option A (git clone)**
- Clone this repo into:
- `ComfyUI/custom_nodes/ComfyUI_Viewer`
**Option A (git clone)**
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/WASasquatch/ComfyUI_Viewer.git
```
- **Option B (Manager)**
- Open Manager and install **ComfyUI_Viewer**
**Option B (ComfyUI Manager)**
- Open Manager and search for **ComfyUI_Viewer**
- **Option C (zip)**
- Download the repo as a zip.
- Extract it to:
- `ComfyUI/custom_nodes/ComfyUI_Viewer`
**Option C (zip)**
- Download and extract to `ComfyUI/custom_nodes/ComfyUI_Viewer`
---
## 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
3. Content type is auto-detected and rendered with the appropriate view
### As a Notes/Documentation Node
1. Add the **Content Viewer** node
@@ -86,24 +171,250 @@ A versatile ComfyUI custom node that renders HTML, SVG, Markdown, and code conte
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
4. Click **Download** to get a `.zip` file with each item as a separate file
### Using the Canvas Compositor
1. Connect IMAGE outputs to the Canvas Composer node
2. Images appear in the source panel - drag them onto the canvas
3. Use transform handles to position and scale
4. Use brush tools to paint or mask
5. Click **Send to Output** to pass the composite downstream
## Supported Content Types
---
| Type | Detection | File Extension |
|------|-----------|----------------|
| **HTML** | `<!DOCTYPE`, `<html`, or common HTML tags | `.html` |
| **SVG** | `<svg` tag with optional XML declaration and valid xmlns attribute | `.svg` |
| **Markdown** | Headers (`#`), lists, links, images (`![]()`), code blocks, tables | `.md` |
| **Python** | `import`, `def`, `class`, `self.` | `.py` |
| **JavaScript** | `function`, `const`, `let`, `var`, arrow functions | `.js` |
| **CSS** | Selectors with `{}`, properties with `:` and `;` | `.css` |
| **Text** | Default fallback | `.txt` |
## How to Add Extensions
The Content Viewer has a dual extension system: **frontend views** for rendering content in the browser, and **backend parsers** for processing Python objects into displayable data.
### Extension Architecture Overview
```
┌─────────────────────────────────────────────────────────────┐
│ ComfyUI │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ Input Data │────▶│Backend Parser│────▶│ Frontend │ │
│ │ (tensors, │ │ (Python) │ │ View (JS) │ │
│ │ objects) │ │ │ │ │ │
│ └─────────────┘ └──────────────┘ └─────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ display_content ──────▶ render() │
└─────────────────────────────────────────────────────────────┘
```
- **Backend Parsers** (`modules/parsers/*_parser.py`) - Process Python objects (tensors, images, custom types) into JSON/strings for the frontend
- **Frontend Views** (`web/views/*.js`) - Detect content patterns and render HTML in the sandboxed iframe
---
## Adding a Frontend View
Frontend views handle content rendering in the browser. They detect content patterns and produce HTML.
### Quick Start
1. **Create your view file** in `web/views/`:
```javascript
// web/views/myview.js
import { BaseView, escapeHtml } from "./base_view.js";
class MyView extends BaseView {
static id = "myview"; // Unique identifier
static displayName = "My View"; // Shown in UI
static priority = 50; // Higher = checked first
// Return score > 0 if this view should handle the content
static detect(content) {
if (content.includes("MY_SPECIAL_MARKER")) {
return 100; // High confidence
}
return 0; // Cannot handle
}
// Render content to HTML for the iframe
static render(content, theme) {
return `<div style="color: ${theme.fg}; background: ${theme.bg};">
<h1>My Custom View</h1>
<pre>${escapeHtml(content)}</pre>
</div>`;
}
// Optional: Add custom CSS
static getStyles(theme) {
return `
body { padding: 16px; }
h1 { color: ${theme.accent}; }
`;
}
}
export default MyView;
```
2. **Register in the manifest** - Add your file to `web/views/view_manifest.js`:
```javascript
export const VIEW_FILES = [
"canvas.js",
"html.js",
// ... existing views ...
"myview.js", // Add your view here
];
```
3. **Restart ComfyUI** - Your view will be automatically loaded
### BaseView Interface
All views extend `BaseView` and can override these methods:
| Method | Required | Description |
|--------|----------|-------------|
| `static id` | ✓ | Unique string identifier |
| `static displayName` | ✓ | Human-readable name for UI |
| `static priority` | ✓ | Detection priority (higher = checked first) |
| `static detect(content)` | ✓ | Return score (0-100+) for content matching |
| `static render(content, theme)` | ✓ | Return HTML string for iframe body |
| `static getStyles(theme)` | | Return CSS string for iframe |
| `static getScripts()` | | Return `<script>` tags for iframe |
| `static loadScripts(basePath)` | | Async load external scripts |
| `static isReady()` | | Return false if scripts still loading |
| `static getContentMarker()` | | Return marker prefix (e.g., `$MY_VIEW$`) |
| `static getMessageTypes()` | | Return array of postMessage types to handle |
| `static handleMessage(type, data, node, app, source)` | | Handle iframe messages |
| `static getStateFromWidget(node)` | | Extract saved state from node |
| `static injectState(content, state)` | | Inject state into content before render |
| `static usesBaseStyles()` | | Return false to skip base iframe CSS |
### Theme Tokens
The `theme` object passed to `render()` and `getStyles()` contains:
```javascript
{
bg: "#1a1a1a", // Background color
fg: "#cccccc", // Foreground text color
border: "#333333", // Border color
accent: "#4a9eff", // Accent/link color
// ... additional tokens
}
```
---
## Adding a Backend Parser
Backend parsers process Python objects (tensors, custom types) into content the frontend can display. They are required when:
- Input data is **not a string** (e.g., IMAGE tensors, model objects)
- You need to **convert Python objects** to JSON/strings for the frontend view
- Your view needs to **output data back to the workflow** (e.g., Canvas outputs IMAGE tensors)
### Parser Discovery
Parsers are **auto-discovered** from `*_parser.py` files in `modules/parsers/`. Simply create a file ending in `_parser.py` and define a class extending `BaseParser`.
### Minimal Example: Dictionary Parser
This example shows a simple parser that handles Python dictionaries:
```python
# modules/parsers/dict_parser.py
"""Minimal parser example - handles Python dictionaries."""
import json
import hashlib
from .base_parser import BaseParser
class DictParser(BaseParser):
"""Parser for dictionary input."""
PARSER_NAME = "dict"
PARSER_PRIORITY = 50
OUTPUT_MARKER = "$WAS_DICT$"
@classmethod
def detect_input(cls, content) -> bool:
"""Return True if content is a dictionary."""
return isinstance(content, dict)
@classmethod
def handle_input(cls, content, logger=None) -> dict:
"""Convert dictionary to JSON for frontend display."""
json_str = json.dumps(content, indent=2, default=str)
content_hash = hashlib.md5(json_str.encode()).hexdigest()[:8]
return {
"display_content": cls.OUTPUT_MARKER + json_str,
"output_values": [content],
"content_hash": f"dict_{content_hash}",
}
@classmethod
def detect_output(cls, content: str) -> bool:
"""Check if content has our marker."""
return isinstance(content, str) and content.startswith(cls.OUTPUT_MARKER)
@classmethod
def parse_output(cls, content: str, logger=None) -> dict:
"""Parse JSON back to dictionary."""
json_str = content[len(cls.OUTPUT_MARKER):]
data = json.loads(json_str)
return {
"output_values": [data],
"display_text": f"Dict with {len(data)} keys",
"content_hash": f"dict_out_{hash(json_str[:50]) & 0xFFFFFFFF}",
}
```
### BaseParser Interface Reference
| Method | Required | Description |
|--------|----------|-------------|
| `PARSER_NAME` | ✓ | Unique string identifier (should match frontend view id) |
| `PARSER_PRIORITY` | ✓ | Detection priority (higher = checked first) |
| `OUTPUT_MARKER` | | String prefix for output content detection |
| `detect_input(content)` | | Return `True` if parser handles this input type |
| `handle_input(content, logger)` | | Process input, return `{display_content, output_values, content_hash}` |
| `detect_output(content)` | | Return `True` if content has this parser's output marker |
| `parse_output(content, logger)` | | Convert frontend output to backend types |
| `detect_state(state_data)` | | Return `True` if parser handles this state data |
| `parse_state(state_data, logger)` | | Parse view state from frontend |
| `detect_display_content(content)` | | Return `True` if parser should prepare display content |
| `prepare_display(content, logger)` | | Prepare content for display |
| `get_default_outputs(content, output_types, logger)` | | Return default output values |
### Parser + View Integration
For a complete extension, you need both a backend parser and frontend view:
1. **Parser** (`modules/parsers/myext_parser.py`):
- `detect_input()` → Returns `True` for your data type
- `handle_input()` → Converts Python objects to JSON with marker prefix
- `parse_output()` → Converts frontend data back to Python types (if needed)
2. **View** (`web/views/myext.js`):
- `detect()` → Checks for marker prefix or content pattern
- `render()` → Generates HTML from the JSON data
- `handleMessage()` → Sends data back to backend (if needed)
**Marker Convention**: Use `$WAS_VIEWNAME$` prefix (e.g., `$WAS_AUDIO$`) so the frontend view can reliably detect content meant for it.
### Multi-View Detection
When multiple parsers return `True` from `detect_input()` for the same content, the system creates a **multi-view payload**:
```python
# Example: IMAGE tensor detected by both CanvasParser and ObjectParser
# Result: User sees a view switcher to toggle between Canvas and Object views
```
This allows users to choose how they want to visualize the data:
- **Canvas view** for compositing and editing
- **Object view** for inspecting tensor metrics and statistics
---
## Troubleshooting
@@ -111,26 +422,38 @@ When connecting a node that outputs multiple strings:
- Ensure the connected node is executing (queue the workflow)
- Try disconnecting and reconnecting the input
### Errors
Check the browser console (F12) for `[WAS Viewer]` prefixed messages. Error logs include context for debugging and can be reported via GitHub issues.
### View not detecting content
- Check the browser console (F12) for `[WAS Viewer]` messages
- Verify your view's `detect()` returns a score > 0 for your content
- Check priority - higher priority views are tested first
### SVG not rendering
- Ensure the SVG includes proper `xmlns` attribute: `xmlns="http://www.w3.org/2000/svg"`
- Verify the SVG is valid XML
### Canvas images not loading
- Ensure images are in ComfyUI's input directory.
- Search for a folder that looks like `was_viewer_032bb95c` where the last part is a unique hash for that nodes canvas content.
- Check for CORS errors in browser console
## API
### Custom view not appearing
- Verify the file is listed in `view_manifest.js`
- Check for JavaScript errors in browser console
- Ensure `export default YourViewClass` is present
### Inputs
| Name | Type | Description |
|------|------|-------------|
| `content` | ANY | Content to display (optional, supports lists) |
---
### Outputs
| Name | Type | Description |
|------|------|-------------|
| `content` | STRING[] | Current content as list of strings |
## Third-Party Licenses
### Hidden Widgets
| Name | Description |
|------|-------------|
| `manual_content` | Stores user-edited content (persisted with workflow) |
This project includes the following third-party libraries:
| Library | License | Description | Location |
|---------|---------|-------------|----------|
| [Prism.js](https://prismjs.com/) | MIT | Syntax highlighting for code blocks | `web/views/code_scripts/` |
| [KaTeX](https://katex.org/) | MIT | LaTeX math rendering | `web/views/markdown_scripts/` |
| [Mermaid](https://mermaid.js.org/) | MIT | Diagram and flowchart rendering | `web/views/markdown_scripts/` |
| [KaTeX Fonts](https://katex.org/) | SIL OFL 1.1 | Math fonts for KaTeX | `fonts/` |
Full license texts are included with each library in their respective directories.
---
## License
MIT License - See [LICENSE](LICENSE) for details
+134 -13
View File
@@ -20,10 +20,11 @@ class WASComfyViewer:
"hidden": {
"manual_content": ("STRING", {"default": ""}),
"viewer_meta": ("STRING", {"default": "{}"}),
"view_state": ("STRING", {"default": "{}"}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_TYPES = (any_type,)
RETURN_NAMES = ("content",)
OUTPUT_NODE = True
INPUT_IS_LIST = True
@@ -31,7 +32,7 @@ class WASComfyViewer:
FUNCTION = "run"
CATEGORY = "WAS/View"
def run(self, content=None, manual_content=None, viewer_meta=None):
def run(self, content=None, manual_content=None, viewer_meta=None, view_state=None):
import json
import logging
@@ -71,7 +72,7 @@ class WASComfyViewer:
parsed = json.loads(meta_str)
if isinstance(parsed, dict) and "excluded" in parsed:
excluded = parsed["excluded"] if isinstance(parsed["excluded"], list) else []
except:
except (json.JSONDecodeError, TypeError, KeyError):
excluded = []
content_trimmed = [c[:256] if isinstance(c, str) else str(c)[:256] for c in content]
@@ -81,33 +82,153 @@ class WASComfyViewer:
LIST_SEPARATOR = "\n---LIST_SEPARATOR---\n"
if len(manual_content) > 0 and any(m for m in manual_content):
def has_content(items):
"""Check if list has non-None, non-empty content without evaluating tensor booleans"""
if not items:
return False
for item in items:
if item is None:
continue
if isinstance(item, str) and not item:
continue
return True
return False
# Import parser system
from .modules.parsers import parse_output, handle_all_inputs
# Check view_state for parser output FIRST
# Views store output in view_state with keys ending in "_output" (e.g., canvas_output)
if has_content(view_state):
state_str = to_string(view_state[0]) if len(view_state) == 1 else view_state[0]
try:
state_data = json.loads(state_str) if state_str else {}
# Check all keys ending with _output for parsable content
for key, value in state_data.items():
if key.endswith("_output") and value:
parsed = parse_output(value, logger)
if parsed:
logger.info(f"[WAS Viewer] View state output parsed by: {parsed.get('parser_name', 'unknown')}")
return {
"ui": {
"text": (parsed["display_text"],),
"source_content": (parsed["display_text"],),
"content_hash": (parsed["content_hash"],)
},
"result": (parsed["output_values"],)
}
except json.JSONDecodeError:
pass
# Try input handlers (e.g., IMAGE tensors -> canvas view)
# Use handle_all_inputs to support multi-view content (e.g., tensor can be canvas OR object view)
input_handled = handle_all_inputs(content, logger)
if input_handled:
logger.info(f"[WAS Viewer] Input handled by: {input_handled.get('parser_name', 'unknown')}")
display_text = input_handled["display_content"]
source_content = display_text
content_hash = input_handled["content_hash"]
output_values = input_handled["output_values"]
elif has_content(manual_content):
# Non-parser manual content (parsers already checked above)
combined = to_string(manual_content[0]) if len(manual_content) == 1 else LIST_SEPARATOR.join(to_string(m) for m in manual_content)
values = combined.split(LIST_SEPARATOR) if LIST_SEPARATOR in combined else [combined]
logger.info(f"[WAS Viewer] Using manual_content: {len(values)} items")
elif len(content) > 0 and any(c for c in content):
display_text = LIST_SEPARATOR.join(values)
source_content = LIST_SEPARATOR.join(to_string(c) for c in content) if content else ""
content_hash = str(len(source_content)) + "_" + str(hash(source_content) & 0xFFFFFFFF)
output_values = [v for i, v in enumerate(values) if i not in excluded]
if not output_values:
output_values = [""]
elif has_content(content):
values = [to_string(c) for c in content]
logger.info(f"[WAS Viewer] Using content input: {len(values)} items")
display_text = LIST_SEPARATOR.join(values)
source_content = LIST_SEPARATOR.join(to_string(c) for c in content) if content else ""
content_hash = str(len(source_content)) + "_" + str(hash(source_content) & 0xFFFFFFFF)
output_values = [v for i, v in enumerate(values) if i not in excluded]
if not output_values:
output_values = [""]
else:
values = [""]
logger.info("[WAS Viewer] No content, using empty")
display_text = LIST_SEPARATOR.join(values)
source_content = LIST_SEPARATOR.join(to_string(c) for c in content) if content else ""
content_hash = str(len(source_content)) + "_" + str(hash(source_content) & 0xFFFFFFFF)
output_values = [v for i, v in enumerate(values) if i not in excluded]
if not output_values:
display_text = ""
source_content = ""
content_hash = "empty_0"
output_values = [""]
return {"ui": {"text": (display_text,), "source_content": (source_content,), "content_hash": (content_hash,)}, "result": (output_values,)}
class WASCanvasComposeBatch:
"""Combines two image batches, padding all images with transparency to the largest size."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"optional": {
"images_a": ("IMAGE",),
"images_b": ("IMAGE",),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("images",)
FUNCTION = "run"
CATEGORY = "WAS/View"
def run(self, images_a=None, images_b=None):
import torch
import logging
logger = logging.getLogger("WAS.CanvasComposeBatch")
all_images = []
def add_batch(batch):
if batch is None:
return
if hasattr(batch, 'shape'):
if len(batch.shape) == 4:
for i in range(batch.shape[0]):
all_images.append(batch[i])
elif len(batch.shape) == 3:
all_images.append(batch)
add_batch(images_a)
add_batch(images_b)
if len(all_images) == 0:
return (torch.zeros((1, 64, 64, 4)),)
max_h = max(img.shape[0] for img in all_images)
max_w = max(img.shape[1] for img in all_images)
logger.info(f"[WAS CanvasComposeBatch] Padding {len(all_images)} images to {max_w}x{max_h}")
padded_images = []
for img in all_images:
h, w, c = img.shape
padded = torch.zeros((max_h, max_w, 4), dtype=img.dtype, device=img.device)
y_offset = (max_h - h) // 2
x_offset = (max_w - w) // 2
padded[y_offset:y_offset+h, x_offset:x_offset+w, :c] = img
padded[y_offset:y_offset+h, x_offset:x_offset+w, 3] = 1.0
padded_images.append(padded)
result = torch.stack(padded_images, dim=0)
return (result,)
NODE_CLASS_MAPPINGS = {
"WASComfyViewer": WASComfyViewer,
"WASCanvasComposeBatch": WASCanvasComposeBatch,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"WASComfyViewer": "Content Viewer",
"WASCanvasComposeBatch": "CV Canvas Compose Batch",
}
+99
View File
@@ -0,0 +1,99 @@
KaTeX Fonts
===========
https://katex.org/
The KaTeX fonts are distributed under the SIL Open Font License, Version 1.1.
Copyright (c) 2009-2010, Design Science, Inc. (www.dessci.com)
Copyright (c) 2014-2020 Khan Academy (www.khanacademy.org)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+1
View File
@@ -0,0 +1 @@
"""Parser modules for WAS Content Viewer."""
+389
View File
@@ -0,0 +1,389 @@
"""
Parser module loader for WAS Content Viewer.
Parsers are loaded from *_parser.py files in this directory.
Each parser must define a class that extends BaseParser.
The loader discovers BaseParser subclasses and registers them automatically.
"""
import os
import inspect
import importlib
import logging
from .base_parser import BaseParser
logger = logging.getLogger("WAS.ContentViewer.Parsers")
_parsers = []
_loaded = False
def load_parsers():
"""Load all parser classes from this directory."""
global _parsers, _loaded
if _loaded:
return _parsers
parsers_dir = os.path.dirname(__file__)
package_name = __name__ # "modules.parsers" or similar
for filename in os.listdir(parsers_dir):
if not filename.endswith("_parser.py") or filename == "base_parser.py":
continue
module_name = filename[:-3] # Remove .py
full_module_name = f"{package_name}.{module_name}"
try:
module = importlib.import_module(full_module_name)
# Find BaseParser subclasses in the module
for name, obj in inspect.getmembers(module, inspect.isclass):
if obj is BaseParser:
continue
if not issubclass(obj, BaseParser):
continue
# Register this parser class
parser_info = {
'name': obj.PARSER_NAME,
'priority': obj.PARSER_PRIORITY,
'class': obj,
'detect_input': obj.detect_input,
'handle_input': obj.handle_input,
'detect_output': obj.detect_output,
'parse_output': obj.parse_output,
}
_parsers.append(parser_info)
logger.info(f"[Parsers] Loaded parser: {obj.PARSER_NAME} (priority {obj.PARSER_PRIORITY})")
except Exception as e:
logger.error(f"[Parsers] Failed to load {filename}: {e}")
# Sort by priority (highest first)
_parsers.sort(key=lambda p: p['priority'], reverse=True)
_loaded = True
return _parsers
def get_parsers():
"""Get all loaded parsers, loading them if necessary."""
if not _loaded:
load_parsers()
return _parsers
def get_parser_by_name(name: str):
"""Get a specific parser by name."""
for parser in get_parsers():
if parser['name'] == name:
return parser
return None
def get_all_parser_names():
"""Get list of all loaded parser names."""
return [p['name'] for p in get_parsers()]
def find_input_handler(content):
"""Find the first parser that can handle this input content."""
for parser in get_parsers():
if not parser['detect_input']:
continue
try:
if parser['detect_input'](content):
return parser
except Exception as e:
logger.error(f"[Parsers] Error in {parser['name']}.detect_input(): {e}")
return None
def find_output_parser(content: str):
"""Find the first parser that can parse this output content."""
for parser in get_parsers():
if not parser['detect_output']:
continue
try:
if parser['detect_output'](content):
return parser
except Exception as e:
logger.error(f"[Parsers] Error in {parser['name']}.detect_output(): {e}")
return None
def handle_input(content, logger=None):
"""
Try to handle input content using available parsers.
Returns:
dict with keys: display_content, output_values, content_hash
or None if no parser matched
"""
parser = find_input_handler(content)
if parser is None:
return None
try:
result = parser['handle_input'](content, logger)
if result:
result['parser_name'] = parser['name']
return result
except Exception as e:
if logger:
logger.error(f"[Parsers] Error in {parser['name']}.handle_input(): {e}")
return None
MULTIVIEW_MARKER = "$WAS_MULTIVIEW$"
def find_all_input_handlers(content):
"""Find ALL parsers that can handle this input content."""
handlers = []
for parser in get_parsers():
if not parser['detect_input']:
continue
try:
if parser['detect_input'](content):
handlers.append(parser)
except Exception as e:
logger.error(f"[Parsers] Error in {parser['name']}.detect_input(): {e}")
return handlers
def handle_all_inputs(content, logger=None):
"""
Try to handle input content using ALL matching parsers.
Returns multi-view payload if multiple parsers match.
Returns:
dict with keys:
- If single match: display_content, output_values, content_hash, parser_name
- If multi match: display_content (with MULTIVIEW_MARKER), output_values,
content_hash, views (list of view data)
or None if no parser matched
"""
import json
handlers = find_all_input_handlers(content)
if not handlers:
return None
# Single handler - return as before
if len(handlers) == 1:
parser = handlers[0]
try:
result = parser['handle_input'](content, logger)
if result:
result['parser_name'] = parser['name']
return result
except Exception as e:
if logger:
logger.error(f"[Parsers] Error in {parser['name']}.handle_input(): {e}")
return None
# Multiple handlers - create multi-view payload
views = []
output_values = None
for parser in handlers:
try:
result = parser['handle_input'](content, logger)
if result:
view_data = {
"name": parser['name'],
"priority": parser['priority'],
"display_content": result.get('display_content', ''),
"content_hash": result.get('content_hash', ''),
}
views.append(view_data)
# Use output_values from highest priority parser
if output_values is None:
output_values = result.get('output_values', [])
except Exception as e:
if logger:
logger.error(f"[Parsers] Error in {parser['name']}.handle_input(): {e}")
if not views:
return None
# Sort by priority (highest first) - first view is default
views.sort(key=lambda v: v['priority'], reverse=True)
# Create multi-view payload
multiview_data = {
"type": "multiview",
"default_view": views[0]['name'],
"views": views,
}
if logger:
view_names = [v['name'] for v in views]
logger.info(f"[Parsers] Multi-view content detected: {view_names}")
return {
"display_content": MULTIVIEW_MARKER + json.dumps(multiview_data),
"output_values": output_values,
"content_hash": f"multiview_{len(views)}_{views[0]['content_hash']}",
"parser_name": "multiview",
"is_multiview": True,
"available_views": [v['name'] for v in views],
}
def parse_output(content: str, logger=None):
"""
Try to parse output content using available parsers.
Returns:
dict with keys: output_values, display_text, content_hash
or None if no parser matched
"""
parser = find_output_parser(content)
if parser is None:
return None
try:
result = parser['parse_output'](content, logger)
if result:
result['parser_name'] = parser['name']
return result
except Exception as e:
if logger:
logger.error(f"[Parsers] Error in {parser['name']}.parse_output(): {e}")
return None
def find_state_parser(state_data: dict):
"""Find the first parser that can handle this state data."""
if not isinstance(state_data, dict):
return None
for parser in get_parsers():
parser_class = parser['class']
if not hasattr(parser_class, 'detect_state'):
continue
try:
if parser_class.detect_state(state_data):
return parser
except Exception as e:
logger.error(f"[Parsers] Error in {parser['name']}.detect_state(): {e}")
return None
def parse_state(state_data, logger=None):
"""
Try to parse state data using available parsers.
Args:
state_data: dict or JSON string of state data
logger: Optional logger
Returns:
dict with parser-specific keys, or None if no parser matched
"""
import json
if isinstance(state_data, str):
if not state_data or state_data == "{}":
return None
try:
state_data = json.loads(state_data)
except json.JSONDecodeError:
return None
if isinstance(state_data, list):
state_data = state_data[0] if state_data else None
if isinstance(state_data, str):
try:
state_data = json.loads(state_data)
except json.JSONDecodeError:
return None
if not isinstance(state_data, dict):
return None
parser = find_state_parser(state_data)
if parser is None:
return None
try:
result = parser['class'].parse_state(state_data, logger)
if result:
result['parser_name'] = parser['name']
return result
except Exception as e:
if logger:
logger.error(f"[Parsers] Error in {parser['name']}.parse_state(): {e}")
return None
def find_display_handler(content):
"""Find the first parser that can prepare display content for this input."""
for parser in get_parsers():
parser_class = parser['class']
if not hasattr(parser_class, 'detect_display_content'):
continue
try:
if parser_class.detect_display_content(content):
return parser
except Exception as e:
logger.error(f"[Parsers] Error in {parser['name']}.detect_display_content(): {e}")
return None
def prepare_display(content, logger=None):
"""
Try to prepare display content using available parsers.
Returns:
dict with keys: display_content, content_hash, count (optional)
or None if no parser matched
"""
parser = find_display_handler(content)
if parser is None:
return None
try:
result = parser['class'].prepare_display(content, logger)
if result:
result['parser_name'] = parser['name']
return result
except Exception as e:
if logger:
logger.error(f"[Parsers] Error in {parser['name']}.prepare_display(): {e}")
return None
def get_default_outputs(content, output_types: list, logger=None):
"""
Try to get default outputs using available parsers.
Args:
content: Input content
output_types: List of expected output type names (e.g., ["IMAGE", "MASK"])
logger: Optional logger
Returns:
tuple of default values, or None if no parser matched
"""
for parser in get_parsers():
parser_class = parser['class']
if not hasattr(parser_class, 'get_default_outputs'):
continue
try:
result = parser_class.get_default_outputs(content, output_types, logger)
if result is not None:
return result
except Exception as e:
if logger:
logger.error(f"[Parsers] Error in {parser['name']}.get_default_outputs(): {e}")
return None
+13
View File
@@ -0,0 +1,13 @@
"""
ANSI Parser for WAS Content Viewer.
"""
from .base_parser import BaseParser
class AnsiParser(BaseParser):
"""ANSI parser for terminal output with ANSI escape codes."""
PARSER_NAME = "ansi"
PARSER_PRIORITY = 10
+138
View File
@@ -0,0 +1,138 @@
"""
Base Parser for WAS Content Viewer.
All view parsers must extend BaseParser. The parser loader discovers and
registers subclasses automatically from *_parser.py files.
Parser Interface:
- PARSER_NAME: str - Unique identifier matching frontend view id
- PARSER_PRIORITY: int - Higher priority parsers are checked first
- detect_input(content) -> bool - Returns True if this parser handles this input
- handle_input(content, logger) -> dict - Process input for display
- detect_output(content: str) -> bool - Returns True if content has output marker
- parse_output(content: str, logger) -> dict - Convert output to backend types
"""
from abc import ABC
class BaseParser(ABC):
"""
Abstract base class for all view parsers.
Subclasses must define PARSER_NAME. Override methods as needed.
Default implementations return False/None for passthrough behavior.
"""
PARSER_NAME: str = "base"
PARSER_PRIORITY: int = 0
OUTPUT_MARKER: str = None
@classmethod
def detect_input(cls, content) -> bool:
"""
Check if this parser should handle the input content.
Override to detect specific content types (e.g., IMAGE tensors).
Return False for passthrough parsers.
"""
return False
@classmethod
def handle_input(cls, content, logger=None) -> dict:
"""
Process input content for display.
Returns:
dict with keys:
- display_content: str - Content for frontend
- output_values: list - Values to pass through
- content_hash: str - Hash for caching
or None if not handling
"""
return None
@classmethod
def detect_output(cls, content: str) -> bool:
"""
Check if content contains this parser's output marker.
Override if parser converts frontend output to backend types.
"""
if cls.OUTPUT_MARKER and isinstance(content, str):
return content.startswith(cls.OUTPUT_MARKER)
return False
@classmethod
def parse_output(cls, content: str, logger=None) -> dict:
"""
Parse output content and convert to backend types.
Returns:
dict with keys:
- output_values: list - Converted values
- display_text: str - Text to show in UI
- content_hash: str - Hash for caching
or None if not handling
"""
return None
@classmethod
def detect_state(cls, state_data: dict) -> bool:
"""
Check if this parser should handle the given state data.
Override to detect specific state formats (e.g., canvas state with dataUrl).
Return False for parsers that don't handle state.
"""
return False
@classmethod
def parse_state(cls, state_data: dict, logger=None) -> dict:
"""
Parse state data from frontend and convert to backend types.
Returns:
dict with parser-specific keys (e.g., image, mask for canvas)
or None if not handling
"""
return None
@classmethod
def detect_display_content(cls, content) -> bool:
"""
Check if this parser should prepare display content for the given input.
Override to detect specific content types for display preparation.
Return False for parsers that don't handle display preparation.
"""
return False
@classmethod
def prepare_display(cls, content, logger=None) -> dict:
"""
Prepare content for frontend display.
Returns:
dict with keys:
- display_content: str - JSON or content for frontend
- content_hash: str - Hash for caching
- count: int - Number of items (optional)
or None if not handling
"""
return None
@classmethod
def get_default_outputs(cls, content, output_types: list, logger=None) -> tuple:
"""
Get default output values based on input content and expected output types.
Args:
content: Input content
output_types: List of expected output type names (e.g., ["IMAGE", "MASK"])
logger: Optional logger
Returns:
tuple of default values matching output_types, or None if not handling
"""
return None
+379
View File
@@ -0,0 +1,379 @@
"""
Canvas Parser for WAS Content Viewer.
Handles both:
- INPUT: IMAGE tensors -> saves to input files (persistent), returns canvas marker JSON for frontend
- OUTPUT: Canvas composite (base64 PNG) -> converts to IMAGE tensor for backend
"""
import base64
import io
import os
import json
import uuid
import hashlib
from .base_parser import BaseParser
class CanvasParser(BaseParser):
"""Canvas parser for IMAGE tensor input and composite output."""
PARSER_NAME = "canvas"
PARSER_PRIORITY = 100 # High priority - check before generic parsers
# Markers for content identification
CANVAS_MARKER = "$WAS_CANVAS$"
OUTPUT_MARKER = "$WAS_CANVAS_OUTPUT$"
CANVAS_TYPE = "canvas_composer"
@classmethod
def detect_input(cls, content) -> bool:
"""Check if content contains IMAGE tensors that should be displayed in canvas view."""
if content is None:
return False
items = content if isinstance(content, (list, tuple)) else [content]
for item in items:
if cls._is_image_tensor(item):
return True
return False
@classmethod
def handle_input(cls, content, logger=None) -> dict:
"""
Process IMAGE tensors and prepare them for canvas view display.
Saves tensors as temp PNG files and returns canvas marker JSON.
"""
items = content if isinstance(content, (list, tuple)) else [content]
session_id = str(uuid.uuid4())[:8]
image_files = []
for item in items:
if cls._is_image_tensor(item):
files = cls._tensor_to_input_files(item, session_id, logger)
image_files.extend(files)
if not image_files:
return None
canvas_data = {
"type": "canvas_composer",
"images": image_files,
"session_id": session_id,
"count": len(image_files),
}
display_content = cls.CANVAS_MARKER + json.dumps(canvas_data)
content_hash = f"canvas_{session_id}_{len(image_files)}"
if logger:
logger.info(f"[Canvas Parser] Processed {len(image_files)} images for canvas view")
return {
"display_content": display_content,
"output_values": list(items),
"content_hash": content_hash,
}
@classmethod
def detect_output(cls, content: str) -> bool:
"""Check if content is canvas composite output (base64 image)."""
if not isinstance(content, str):
return False
return content.startswith(cls.OUTPUT_MARKER)
@classmethod
def parse_output(cls, content: str, logger=None) -> dict:
"""Parse canvas composite output and convert to IMAGE tensor."""
import torch
import numpy as np
from PIL import Image
base64_data = content[len(cls.OUTPUT_MARKER):]
if base64_data.startswith("data:"):
base64_data = base64_data.split(",", 1)[1]
try:
image_bytes = base64.b64decode(base64_data)
pil_img = Image.open(io.BytesIO(image_bytes)).convert("RGBA")
img_array = np.array(pil_img).astype(np.float32) / 255.0
img_tensor = torch.from_numpy(img_array).unsqueeze(0)
if logger:
logger.info(f"[Canvas Parser] Converted composite to IMAGE tensor: {img_tensor.shape}")
return {
"output_values": [img_tensor],
"display_text": f"Canvas Output: {pil_img.width}x{pil_img.height} RGBA",
"content_hash": f"canvas_output_{pil_img.width}x{pil_img.height}_{hash(base64_data[:100]) & 0xFFFFFFFF}",
}
except Exception as e:
if logger:
logger.error(f"[Canvas Parser] Failed to convert composite: {e}")
return None
@staticmethod
def _is_image_tensor(item) -> bool:
"""Check if item is an IMAGE tensor (torch tensor with shape [B,H,W,C] or [H,W,C])."""
if item is None:
return False
if hasattr(item, 'shape') and hasattr(item, 'cpu'):
shape = item.shape
if len(shape) == 4 and shape[-1] in (1, 3, 4):
return True
if len(shape) == 3 and shape[-1] in (1, 3, 4):
return True
return False
@staticmethod
def _tensor_to_input_files(tensor, session_id, logger=None):
"""Convert IMAGE tensor to input PNG files (persists across restarts)."""
import numpy as np
from PIL import Image
import folder_paths
input_dir = folder_paths.get_input_directory()
subdir = f"was_viewer_{session_id}"
full_subdir = os.path.join(input_dir, subdir)
os.makedirs(full_subdir, exist_ok=True)
files = []
if len(tensor.shape) == 3:
tensor = tensor.unsqueeze(0)
batch_size = tensor.shape[0]
for idx in range(batch_size):
img_array = tensor[idx].cpu().numpy()
img_array = (img_array * 255).astype(np.uint8)
if img_array.shape[-1] == 4:
pil_img = Image.fromarray(img_array, mode='RGBA')
pil_img = CanvasParser._trim_transparency(pil_img)
elif img_array.shape[-1] == 3:
pil_img = Image.fromarray(img_array, mode='RGB')
else:
pil_img = Image.fromarray(img_array)
img_hash = hashlib.md5(img_array.tobytes()).hexdigest()[:12]
filename = f"{idx:04d}_{img_hash}.png"
filepath = os.path.join(full_subdir, filename)
if not os.path.exists(filepath):
pil_img.save(filepath, format='PNG')
if logger:
logger.debug(f"[Canvas Parser] Saved: {filepath}")
files.append({
"filename": filename,
"subfolder": subdir,
"type": "input"
})
return files
@staticmethod
def _trim_transparency(pil_img, padding=0):
"""Trim transparent pixels from image edges."""
import numpy as np
if pil_img.mode != 'RGBA':
return pil_img
alpha = np.array(pil_img.split()[-1])
rows = np.any(alpha > 0, axis=1)
cols = np.any(alpha > 0, axis=0)
if not rows.any() or not cols.any():
return pil_img
rmin, rmax = np.where(rows)[0][[0, -1]]
cmin, cmax = np.where(cols)[0][[0, -1]]
rmin = max(0, rmin - padding)
rmax = min(pil_img.height - 1, rmax + padding)
cmin = max(0, cmin - padding)
cmax = min(pil_img.width - 1, cmax + padding)
return pil_img.crop((cmin, rmin, cmax + 1, rmax + 1))
@classmethod
def detect_state(cls, state_data: dict) -> bool:
"""Check if state data contains canvas dataUrl."""
return isinstance(state_data, dict) and "dataUrl" in state_data
@classmethod
def parse_state(cls, state_data: dict, logger=None) -> dict:
"""
Parse canvas state containing composed image dataUrl.
Returns:
dict with keys: image (IMAGE tensor), mask (MASK tensor), width, height,
display_content (JSON for UI)
or None if invalid
"""
import torch
import numpy as np
from PIL import Image
if not isinstance(state_data, dict) or "dataUrl" not in state_data:
return None
try:
data_url = state_data["dataUrl"]
if "," in data_url:
b64_data = data_url.split(",", 1)[1]
else:
b64_data = data_url
img_bytes = base64.b64decode(b64_data)
pil_img = Image.open(io.BytesIO(img_bytes))
if pil_img.mode == 'RGBA':
alpha_channel = pil_img.split()[3]
alpha_array = np.array(alpha_channel).astype(np.float32) / 255.0
alpha_mask = torch.from_numpy(alpha_array).unsqueeze(0)
rgb_img = Image.new('RGB', pil_img.size, (0, 0, 0))
rgb_img.paste(pil_img, mask=alpha_channel)
pil_img = rgb_img
else:
alpha_mask = torch.ones((1, pil_img.height, pil_img.width))
if pil_img.mode != 'RGB':
pil_img = pil_img.convert('RGB')
img_array = np.array(pil_img).astype(np.float32) / 255.0
composed_image = torch.from_numpy(img_array).unsqueeze(0)
if logger:
logger.info(f"[Canvas Parser] Loaded composed image from state: {pil_img.width}x{pil_img.height}")
canvas_data = {
"type": cls.CANVAS_TYPE,
"images": [],
"count": 0,
"has_output": True,
}
return {
"image": composed_image,
"mask": alpha_mask,
"width": pil_img.width,
"height": pil_img.height,
"display_content": json.dumps(canvas_data),
"content_hash": "composed",
}
except Exception as e:
if logger:
logger.error(f"[Canvas Parser] Error decoding state: {e}")
return None
@classmethod
def detect_display_content(cls, content) -> bool:
"""Check if content contains IMAGE tensors for display."""
if content is None:
return False
items = content if isinstance(content, (list, tuple)) else [content]
for item in items:
if cls._is_image_tensor(item):
return True
return False
@classmethod
def prepare_display(cls, content, logger=None) -> dict:
"""
Convert IMAGE tensors to base64 data URLs for canvas display.
Returns:
dict with keys: display_content (JSON string), count, content_hash
"""
import numpy as np
from PIL import Image
images = content if isinstance(content, (list, tuple)) else [content]
base64_images = []
try:
for img_batch in images:
if img_batch is None:
continue
if hasattr(img_batch, 'shape'):
if len(img_batch.shape) == 4:
for i in range(img_batch.shape[0]):
img_array = img_batch[i].cpu().numpy() if hasattr(img_batch[i], 'cpu') else img_batch[i]
img_array = (img_array * 255).astype(np.uint8)
pil_img = Image.fromarray(img_array)
buffer = io.BytesIO()
pil_img.save(buffer, format='PNG')
b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
base64_images.append(f"data:image/png;base64,{b64}")
elif len(img_batch.shape) == 3:
img_array = img_batch.cpu().numpy() if hasattr(img_batch, 'cpu') else img_batch
img_array = (img_array * 255).astype(np.uint8)
pil_img = Image.fromarray(img_array)
buffer = io.BytesIO()
pil_img.save(buffer, format='PNG')
b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
base64_images.append(f"data:image/png;base64,{b64}")
except Exception as e:
if logger:
logger.error(f"[Canvas Parser] Error converting images: {e}")
canvas_data = {
"type": cls.CANVAS_TYPE,
"images": base64_images,
"count": len(base64_images),
}
if logger:
logger.info(f"[Canvas Parser] Processed {len(base64_images)} images")
return {
"display_content": json.dumps(canvas_data),
"count": len(base64_images),
"content_hash": str(len(base64_images)),
}
@classmethod
def get_default_outputs(cls, content, output_types: list, logger=None) -> tuple:
"""
Get default IMAGE and MASK outputs based on input content.
Returns:
tuple matching output_types, or None if not applicable
"""
import torch
if not output_types or set(output_types) != {"IMAGE", "MASK"}:
return None
images = content if isinstance(content, (list, tuple)) else [content]
if images and len(images) > 0 and images[0] is not None:
output_image = images[0]
if hasattr(images[0], 'shape') and len(images[0].shape) >= 3:
h, w = images[0].shape[1], images[0].shape[2]
output_mask = torch.ones((1, h, w))
else:
output_mask = torch.ones((1, 64, 64))
else:
output_image = torch.zeros((1, 64, 64, 3))
output_mask = torch.ones((1, 64, 64))
if output_types[0] == "IMAGE":
return (output_image, output_mask)
else:
return (output_mask, output_image)
+13
View File
@@ -0,0 +1,13 @@
"""
CSS Parser for WAS Content Viewer.
"""
from .base_parser import BaseParser
class CssParser(BaseParser):
"""CSS parser for CSS code display."""
PARSER_NAME = "css"
PARSER_PRIORITY = 10
+13
View File
@@ -0,0 +1,13 @@
"""
CSV Parser for WAS Content Viewer.
"""
from .base_parser import BaseParser
class CsvParser(BaseParser):
"""CSV parser for CSV content display."""
PARSER_NAME = "csv"
PARSER_PRIORITY = 10
+13
View File
@@ -0,0 +1,13 @@
"""
HTML Parser for WAS Content Viewer.
"""
from .base_parser import BaseParser
class HtmlParser(BaseParser):
"""HTML parser for HTML content display."""
PARSER_NAME = "html"
PARSER_PRIORITY = 10
+13
View File
@@ -0,0 +1,13 @@
"""
JavaScript Parser for WAS Content Viewer.
"""
from .base_parser import BaseParser
class JavaScriptParser(BaseParser):
"""JavaScript parser for JavaScript code display."""
PARSER_NAME = "javascript"
PARSER_PRIORITY = 10
+13
View File
@@ -0,0 +1,13 @@
"""
JSON Parser for WAS Content Viewer.
"""
from .base_parser import BaseParser
class JsonParser(BaseParser):
"""JSON parser for JSON content display."""
PARSER_NAME = "json"
PARSER_PRIORITY = 10
+13
View File
@@ -0,0 +1,13 @@
"""
Markdown Parser for WAS Content Viewer.
"""
from .base_parser import BaseParser
class MarkdownParser(BaseParser):
"""Markdown parser for markdown content display."""
PARSER_NAME = "markdown"
PARSER_PRIORITY = 10
+869
View File
@@ -0,0 +1,869 @@
"""
Object Parser for WAS Content Viewer.
Handles unrecognized Python objects including:
- Tensors (PyTorch, NumPy)
- PIL Images
- SafeTensors models
- Generic class objects
Generates metrics, spectral data for image types, and trimmed serializations.
"""
import json
import inspect
import sys
from typing import Dict, Optional
from .base_parser import BaseParser
class ObjectParser(BaseParser):
"""Parser for generic Python objects with introspection and metrics."""
PARSER_NAME = "object"
PARSER_PRIORITY = 5 # Low priority - fallback for unrecognized objects
OUTPUT_MARKER = "$WAS_OBJECT$"
# Size limits for serialization
MAX_TENSOR_ELEMENTS_PREVIEW = 100
MAX_STRING_LENGTH = 500
MAX_LIST_ITEMS = 50
MAX_DICT_KEYS = 100
MAX_ATTR_VALUE_LENGTH = 200
@classmethod
def detect_input(cls, content) -> bool:
"""Detect any non-None object that isn't handled by other parsers."""
if content is None:
return False
# Check if it's a list/tuple of items
items = content if isinstance(content, (list, tuple)) else [content]
for item in items:
if item is None:
continue
# Accept any object type - this is our fallback parser
if cls._is_introspectable(item):
return True
return False
@classmethod
def _is_introspectable(cls, obj) -> bool:
"""Check if object is worth introspecting (not a basic string/number)."""
if obj is None:
return False
# Skip basic types that text view handles better
if isinstance(obj, (str, int, float, bool)):
return False
# Accept everything else
return True
@classmethod
def handle_input(cls, content, logger=None) -> dict:
"""Process objects and generate metrics/serialization for display."""
items = content if isinstance(content, (list, tuple)) else [content]
object_data = {
"type": "object_viewer",
"objects": [],
"count": 0,
}
for idx, item in enumerate(items):
if item is None:
continue
obj_info = cls._introspect_object(item, logger)
if obj_info:
obj_info["index"] = idx
object_data["objects"].append(obj_info)
object_data["count"] = len(object_data["objects"])
if object_data["count"] == 0:
return None
display_content = cls.OUTPUT_MARKER + json.dumps(object_data, default=str)
content_hash = f"object_{len(object_data['objects'])}_{hash(str(content)[:100]) & 0xFFFFFFFF}"
if logger:
logger.info(f"[Object Parser] Processed {object_data['count']} objects")
return {
"display_content": display_content,
"output_values": list(items),
"content_hash": content_hash,
}
@classmethod
def _introspect_object(cls, obj, logger=None) -> Optional[Dict]:
"""Generate full introspection data for an object."""
obj_type = type(obj).__name__
module = type(obj).__module__
result = {
"type_name": obj_type,
"module": module,
"full_type": f"{module}.{obj_type}" if module != "builtins" else obj_type,
"category": cls._categorize_object(obj),
"metrics": {},
"spectral": None,
"attributes": {},
"serialized": None,
"source_info": None,
}
# Get category-specific data
category = result["category"]
if category == "tensor":
result["metrics"] = cls._get_tensor_metrics(obj)
result["spectral"] = cls._get_tensor_spectral(obj)
result["serialized"] = cls._serialize_tensor(obj)
elif category == "pil_image":
result["metrics"] = cls._get_pil_metrics(obj)
result["spectral"] = cls._get_pil_spectral(obj)
result["serialized"] = cls._serialize_pil(obj)
elif category == "numpy":
result["metrics"] = cls._get_numpy_metrics(obj)
result["spectral"] = cls._get_numpy_spectral(obj)
result["serialized"] = cls._serialize_numpy(obj)
elif category == "safetensors":
result["metrics"] = cls._get_safetensors_metrics(obj)
result["serialized"] = cls._serialize_safetensors(obj)
elif category == "dict":
result["metrics"] = cls._get_dict_metrics(obj)
result["serialized"] = cls._serialize_dict(obj)
elif category == "list":
result["metrics"] = cls._get_list_metrics(obj)
result["serialized"] = cls._serialize_list(obj)
else:
# Generic object
result["attributes"] = cls._get_object_attributes(obj)
result["metrics"] = cls._get_object_metrics(obj)
result["source_info"] = cls._get_source_info(obj)
result["serialized"] = cls._serialize_object(obj)
return result
@classmethod
def _categorize_object(cls, obj) -> str:
"""Determine the category of an object."""
obj_type = type(obj).__name__
module = type(obj).__module__
# Check for tensor types
if hasattr(obj, 'shape') and hasattr(obj, 'dtype'):
if 'torch' in module:
return "tensor"
if 'numpy' in module or obj_type == 'ndarray':
return "numpy"
# Check for PIL Image
if 'PIL' in module or obj_type == 'Image':
return "pil_image"
# Check for safetensors
if 'safetensors' in module or hasattr(obj, 'keys') and hasattr(obj, 'get_tensor'):
return "safetensors"
# Basic collections
if isinstance(obj, dict):
return "dict"
if isinstance(obj, (list, tuple)):
return "list"
return "object"
# ========== TENSOR METHODS ==========
@classmethod
def _get_tensor_metrics(cls, tensor) -> Dict:
"""Get metrics for PyTorch tensor."""
try:
metrics = {
"shape": list(tensor.shape),
"dtype": str(tensor.dtype),
"device": str(tensor.device),
"numel": int(tensor.numel()),
"ndim": int(tensor.ndim),
"requires_grad": bool(tensor.requires_grad),
"is_contiguous": bool(tensor.is_contiguous()),
}
# Memory info
metrics["memory_bytes"] = tensor.element_size() * tensor.numel()
metrics["memory_human"] = cls._format_bytes(metrics["memory_bytes"])
# Statistical metrics (on CPU for safety)
try:
t = tensor.detach().float()
if t.device.type != 'cpu':
t = t.cpu()
metrics["stats"] = {
"min": float(t.min()),
"max": float(t.max()),
"mean": float(t.mean()),
"std": float(t.std()),
}
# Check for image-like tensor
if len(tensor.shape) >= 3:
if tensor.shape[-1] in (1, 3, 4): # HWC format
metrics["image_format"] = "HWC"
metrics["resolution"] = f"{tensor.shape[-3]}x{tensor.shape[-2]}"
metrics["channels"] = int(tensor.shape[-1])
elif tensor.shape[-3] in (1, 3, 4): # CHW format
metrics["image_format"] = "CHW"
metrics["resolution"] = f"{tensor.shape[-2]}x{tensor.shape[-1]}"
metrics["channels"] = int(tensor.shape[-3])
except Exception:
pass
return metrics
except Exception as e:
return {"error": str(e)}
@classmethod
def _get_tensor_spectral(cls, tensor) -> Optional[Dict]:
"""Generate spectral/histogram data for tensor."""
try:
import torch
t = tensor.detach().float()
if t.device.type != 'cpu':
t = t.cpu()
# Check if it looks like an image tensor
shape = tensor.shape
is_image = False
channels = 0
if len(shape) >= 3:
if shape[-1] in (1, 3, 4): # HWC
is_image = True
channels = shape[-1]
# Flatten batch dimensions if present
if len(shape) == 4:
t = t[0] # Take first in batch
elif shape[-3] in (1, 3, 4): # CHW
is_image = True
channels = shape[-3]
if len(shape) == 4:
t = t[0]
t = t.permute(1, 2, 0) # Convert to HWC
if not is_image:
# Generate simple histogram for non-image tensor
flat = t.flatten()
hist, bin_edges = torch.histogram(flat, bins=64)
return {
"type": "histogram",
"data": hist.tolist(),
"bins": bin_edges.tolist(),
}
# Generate channel histograms for image
spectral = {
"type": "spectral",
"channels": [],
}
channel_names = ["R", "G", "B", "A"][:channels] if channels <= 4 else [f"C{i}" for i in range(channels)]
for i in range(channels):
channel = t[..., i].flatten()
hist, bin_edges = torch.histogram(channel, bins=64, range=(0.0, 1.0))
spectral["channels"].append({
"name": channel_names[i],
"histogram": hist.tolist(),
"bins": bin_edges.tolist(),
})
return spectral
except Exception:
return None
@classmethod
def _serialize_tensor(cls, tensor) -> str:
"""Create trimmed serialization of tensor."""
try:
t = tensor.detach()
if t.device.type != 'cpu':
t = t.cpu()
flat = t.flatten()
total = len(flat)
if total <= cls.MAX_TENSOR_ELEMENTS_PREVIEW:
data = flat.tolist()
truncated = False
else:
# Show first and last elements
half = cls.MAX_TENSOR_ELEMENTS_PREVIEW // 2
first = flat[:half].tolist()
last = flat[-half:].tolist()
data = first + ["..."] + last
truncated = True
return json.dumps({
"preview": data,
"truncated": truncated,
"total_elements": total,
})
except Exception as e:
return json.dumps({"error": str(e)})
# ========== PIL IMAGE METHODS ==========
@classmethod
def _get_pil_metrics(cls, img) -> Dict:
"""Get metrics for PIL Image."""
try:
metrics = {
"size": list(img.size),
"resolution": f"{img.width}x{img.height}",
"mode": img.mode,
"format": img.format,
"channels": len(img.getbands()),
"bands": img.getbands(),
}
# Color profile info
if hasattr(img, 'info'):
if 'icc_profile' in img.info:
metrics["has_icc_profile"] = True
if 'dpi' in img.info:
metrics["dpi"] = img.info['dpi']
if 'exif' in img.info:
metrics["has_exif"] = True
# Memory estimate
pixels = img.width * img.height
bytes_per_pixel = len(img.getbands())
metrics["memory_bytes"] = pixels * bytes_per_pixel
metrics["memory_human"] = cls._format_bytes(metrics["memory_bytes"])
# Statistical info
try:
import numpy as np
arr = np.array(img)
metrics["stats"] = {
"min": int(arr.min()),
"max": int(arr.max()),
"mean": float(arr.mean()),
"std": float(arr.std()),
}
except Exception:
pass
return metrics
except Exception as e:
return {"error": str(e)}
@classmethod
def _get_pil_spectral(cls, img) -> Optional[Dict]:
"""Generate spectral/histogram data for PIL Image."""
try:
import numpy as np
arr = np.array(img)
channels = arr.shape[-1] if len(arr.shape) == 3 else 1
spectral = {
"type": "spectral",
"channels": [],
}
bands = img.getbands()
if len(arr.shape) == 2:
# Grayscale
hist, bin_edges = np.histogram(arr.flatten(), bins=64, range=(0, 255))
spectral["channels"].append({
"name": "L",
"histogram": hist.tolist(),
"bins": bin_edges.tolist(),
})
else:
for i in range(min(channels, 4)):
channel = arr[..., i].flatten()
hist, bin_edges = np.histogram(channel, bins=64, range=(0, 255))
spectral["channels"].append({
"name": bands[i] if i < len(bands) else f"C{i}",
"histogram": hist.tolist(),
"bins": bin_edges.tolist(),
})
return spectral
except Exception:
return None
@classmethod
def _serialize_pil(cls, img) -> str:
"""Create trimmed serialization of PIL Image."""
try:
info = {
"format": img.format,
"mode": img.mode,
"size": img.size,
"info_keys": list(img.info.keys()) if hasattr(img, 'info') else [],
}
return json.dumps(info)
except Exception as e:
return json.dumps({"error": str(e)})
# ========== NUMPY METHODS ==========
@classmethod
def _get_numpy_metrics(cls, arr) -> Dict:
"""Get metrics for NumPy array."""
try:
metrics = {
"shape": list(arr.shape),
"dtype": str(arr.dtype),
"ndim": int(arr.ndim),
"size": int(arr.size),
"itemsize": int(arr.itemsize),
"memory_bytes": int(arr.nbytes),
"memory_human": cls._format_bytes(arr.nbytes),
"is_contiguous": bool(arr.flags['C_CONTIGUOUS']),
}
# Statistics
try:
metrics["stats"] = {
"min": float(arr.min()),
"max": float(arr.max()),
"mean": float(arr.mean()),
"std": float(arr.std()),
}
except Exception:
pass
# Check for image-like array
if len(arr.shape) >= 2:
if len(arr.shape) == 2:
metrics["image_format"] = "Grayscale"
metrics["resolution"] = f"{arr.shape[1]}x{arr.shape[0]}"
elif arr.shape[-1] in (1, 3, 4):
metrics["image_format"] = "HWC"
metrics["resolution"] = f"{arr.shape[1]}x{arr.shape[0]}"
metrics["channels"] = int(arr.shape[-1])
return metrics
except Exception as e:
return {"error": str(e)}
@classmethod
def _get_numpy_spectral(cls, arr) -> Optional[Dict]:
"""Generate spectral/histogram data for NumPy array."""
try:
import numpy as np
# Check if image-like
is_image = False
if len(arr.shape) >= 2 and len(arr.shape) <= 3:
if len(arr.shape) == 2 or arr.shape[-1] in (1, 3, 4):
is_image = True
if not is_image:
flat = arr.flatten()
hist, bin_edges = np.histogram(flat, bins=64)
return {
"type": "histogram",
"data": hist.tolist(),
"bins": bin_edges.tolist(),
}
spectral = {
"type": "spectral",
"channels": [],
}
# Normalize range based on dtype
if arr.dtype == np.uint8:
range_val = (0, 255)
elif arr.dtype in (np.float32, np.float64):
range_val = (0.0, 1.0)
else:
range_val = (float(arr.min()), float(arr.max()))
channel_names = ["R", "G", "B", "A"]
if len(arr.shape) == 2:
hist, bin_edges = np.histogram(arr.flatten(), bins=64, range=range_val)
spectral["channels"].append({
"name": "L",
"histogram": hist.tolist(),
"bins": bin_edges.tolist(),
})
else:
channels = arr.shape[-1]
for i in range(min(channels, 4)):
channel = arr[..., i].flatten()
hist, bin_edges = np.histogram(channel, bins=64, range=range_val)
spectral["channels"].append({
"name": channel_names[i] if i < len(channel_names) else f"C{i}",
"histogram": hist.tolist(),
"bins": bin_edges.tolist(),
})
return spectral
except Exception:
return None
@classmethod
def _serialize_numpy(cls, arr) -> str:
"""Create trimmed serialization of NumPy array."""
try:
flat = arr.flatten()
total = len(flat)
if total <= cls.MAX_TENSOR_ELEMENTS_PREVIEW:
data = flat.tolist()
truncated = False
else:
half = cls.MAX_TENSOR_ELEMENTS_PREVIEW // 2
first = flat[:half].tolist()
last = flat[-half:].tolist()
data = first + ["..."] + last
truncated = True
return json.dumps({
"preview": data,
"truncated": truncated,
"total_elements": total,
})
except Exception as e:
return json.dumps({"error": str(e)})
# ========== SAFETENSORS METHODS ==========
@classmethod
def _get_safetensors_metrics(cls, obj) -> Dict:
"""Get metrics for SafeTensors object/dict."""
try:
metrics = {
"tensor_count": 0,
"total_parameters": 0,
"total_bytes": 0,
"tensor_info": [],
}
# Handle different safetensors representations
if hasattr(obj, 'keys'):
keys = list(obj.keys())
metrics["tensor_count"] = len(keys)
for key in keys[:50]: # Limit to first 50 for display
try:
if hasattr(obj, 'get_tensor'):
tensor = obj.get_tensor(key)
else:
tensor = obj[key]
if hasattr(tensor, 'shape'):
shape = list(tensor.shape)
numel = 1
for s in shape:
numel *= s
metrics["total_parameters"] += numel
dtype = str(tensor.dtype) if hasattr(tensor, 'dtype') else "unknown"
metrics["tensor_info"].append({
"name": key,
"shape": shape,
"dtype": dtype,
"params": numel,
})
except Exception:
metrics["tensor_info"].append({
"name": key,
"error": "Could not read tensor",
})
if len(keys) > 50:
metrics["tensor_info"].append({
"name": f"... and {len(keys) - 50} more tensors",
"truncated": True,
})
metrics["total_params_human"] = cls._format_number(metrics["total_parameters"])
return metrics
except Exception as e:
return {"error": str(e)}
@classmethod
def _serialize_safetensors(cls, obj) -> str:
"""Create trimmed serialization of SafeTensors."""
try:
if hasattr(obj, 'keys'):
keys = list(obj.keys())
return json.dumps({
"tensor_names": keys[:100],
"truncated": len(keys) > 100,
"total_tensors": len(keys),
})
return json.dumps({"type": "safetensors", "readable": False})
except Exception as e:
return json.dumps({"error": str(e)})
# ========== DICT/LIST METHODS ==========
@classmethod
def _get_dict_metrics(cls, obj: dict) -> Dict:
"""Get metrics for dictionary."""
return {
"key_count": len(obj),
"keys": list(obj.keys())[:cls.MAX_DICT_KEYS],
"truncated_keys": len(obj) > cls.MAX_DICT_KEYS,
"memory_estimate": cls._estimate_size(obj),
}
@classmethod
def _serialize_dict(cls, obj: dict) -> str:
"""Create trimmed serialization of dict."""
try:
trimmed = {}
for i, (k, v) in enumerate(obj.items()):
if i >= cls.MAX_DICT_KEYS:
trimmed["__truncated__"] = f"{len(obj) - cls.MAX_DICT_KEYS} more keys..."
break
trimmed[str(k)] = cls._serialize_value(v)
return json.dumps(trimmed, default=str, indent=2)
except Exception as e:
return json.dumps({"error": str(e)})
@classmethod
def _get_list_metrics(cls, obj) -> Dict:
"""Get metrics for list/tuple."""
return {
"length": len(obj),
"type": type(obj).__name__,
"item_types": list(set(type(x).__name__ for x in obj[:100])),
"memory_estimate": cls._estimate_size(obj),
}
@classmethod
def _serialize_list(cls, obj) -> str:
"""Create trimmed serialization of list."""
try:
if len(obj) <= cls.MAX_LIST_ITEMS:
items = [cls._serialize_value(v) for v in obj]
else:
half = cls.MAX_LIST_ITEMS // 2
items = [cls._serialize_value(v) for v in obj[:half]]
items.append(f"... ({len(obj) - cls.MAX_LIST_ITEMS} more items) ...")
items.extend([cls._serialize_value(v) for v in obj[-half:]])
return json.dumps(items, default=str, indent=2)
except Exception as e:
return json.dumps({"error": str(e)})
# ========== GENERIC OBJECT METHODS ==========
@classmethod
def _get_object_attributes(cls, obj) -> Dict:
"""Get attributes of a generic object."""
attrs = {}
try:
# Get all attributes
for name in dir(obj):
if name.startswith('__'):
continue
try:
value = getattr(obj, name)
if callable(value):
continue # Skip methods
attrs[name] = {
"type": type(value).__name__,
"value": cls._serialize_value(value, max_len=cls.MAX_ATTR_VALUE_LENGTH),
}
if len(attrs) >= cls.MAX_DICT_KEYS:
break
except Exception:
pass
except Exception:
pass
return attrs
@classmethod
def _get_object_metrics(cls, obj) -> Dict:
"""Get metrics for a generic object."""
metrics = {
"type": type(obj).__name__,
"module": type(obj).__module__,
"id": id(obj),
}
# Try to get size
try:
metrics["size_bytes"] = sys.getsizeof(obj)
metrics["size_human"] = cls._format_bytes(metrics["size_bytes"])
except Exception:
pass
# Count attributes
try:
attrs = [a for a in dir(obj) if not a.startswith('__') and not callable(getattr(obj, a, None))]
methods = [m for m in dir(obj) if not m.startswith('__') and callable(getattr(obj, m, None))]
metrics["attribute_count"] = len(attrs)
metrics["method_count"] = len(methods)
except Exception:
pass
# Check for common interfaces
metrics["interfaces"] = []
if hasattr(obj, '__iter__'):
metrics["interfaces"].append("iterable")
if hasattr(obj, '__len__'):
metrics["interfaces"].append("sized")
try:
metrics["length"] = len(obj)
except Exception:
pass
if hasattr(obj, '__call__'):
metrics["interfaces"].append("callable")
if hasattr(obj, '__getitem__'):
metrics["interfaces"].append("subscriptable")
return metrics
@classmethod
def _get_source_info(cls, obj) -> Optional[Dict]:
"""Try to get source file info for the object's class."""
try:
cls_type = type(obj)
source_info = {}
try:
source_info["file"] = inspect.getfile(cls_type)
except Exception:
pass
try:
source_info["source_lines"] = len(inspect.getsourcelines(cls_type)[0])
except Exception:
pass
# Get class hierarchy
try:
mro = [c.__name__ for c in cls_type.__mro__[1:-1]] # Skip self and object
if mro:
source_info["bases"] = mro
except Exception:
pass
return source_info if source_info else None
except Exception:
return None
@classmethod
def _serialize_object(cls, obj) -> str:
"""Create trimmed serialization of generic object."""
try:
# Try repr first
repr_str = repr(obj)
if len(repr_str) > cls.MAX_STRING_LENGTH:
repr_str = repr_str[:cls.MAX_STRING_LENGTH] + "..."
return json.dumps({
"repr": repr_str,
"type": type(obj).__name__,
})
except Exception as e:
return json.dumps({"error": str(e)})
# ========== UTILITY METHODS ==========
@classmethod
def _serialize_value(cls, value, max_len: int = None) -> str:
"""Serialize a single value with truncation."""
max_len = max_len or cls.MAX_ATTR_VALUE_LENGTH
try:
if value is None:
return "null"
if isinstance(value, (bool,)):
return str(value).lower()
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
if len(value) > max_len:
return f'"{value[:max_len]}..."'
return f'"{value}"'
if isinstance(value, (list, tuple)):
if len(value) > 10:
return f"[{type(value).__name__} of {len(value)} items]"
return str(value)[:max_len]
if isinstance(value, dict):
return f"{{dict of {len(value)} keys}}"
if hasattr(value, 'shape'):
return f"<{type(value).__name__} shape={list(value.shape)}>"
s = repr(value)
if len(s) > max_len:
return s[:max_len] + "..."
return s
except Exception:
return "<unserializable>"
@classmethod
def _estimate_size(cls, obj) -> str:
"""Estimate memory size of object."""
try:
size = sys.getsizeof(obj)
return cls._format_bytes(size)
except Exception:
return "unknown"
@staticmethod
def _format_bytes(size: int) -> str:
"""Format bytes to human readable."""
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if abs(size) < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} PB"
@staticmethod
def _format_number(num: int) -> str:
"""Format large number to human readable."""
if num < 1000:
return str(num)
for unit in ['', 'K', 'M', 'B', 'T']:
if abs(num) < 1000:
return f"{num:.1f}{unit}"
num /= 1000
return f"{num:.1f}P"
@classmethod
def detect_output(cls, content: str) -> bool:
"""Check if content has object output marker."""
if not isinstance(content, str):
return False
return content.startswith(cls.OUTPUT_MARKER)
@classmethod
def parse_output(cls, content: str, logger=None) -> dict:
"""Parse object output - typically just pass through."""
# Object view is display-only, no conversion needed
return None
+13
View File
@@ -0,0 +1,13 @@
"""
Python Parser for WAS Content Viewer.
"""
from .base_parser import BaseParser
class PythonParser(BaseParser):
"""Python parser for Python code display."""
PARSER_NAME = "python"
PARSER_PRIORITY = 10
+13
View File
@@ -0,0 +1,13 @@
"""
SVG Parser for WAS Content Viewer.
"""
from .base_parser import BaseParser
class SvgParser(BaseParser):
"""SVG parser for SVG content display."""
PARSER_NAME = "svg"
PARSER_PRIORITY = 10
+15
View File
@@ -0,0 +1,15 @@
"""
Text Parser for WAS Content Viewer.
Handles plain text content.
"""
from .base_parser import BaseParser
class TextParser(BaseParser):
"""Text parser - fallback for plain text content."""
PARSER_NAME = "text"
PARSER_PRIORITY = 1 # Low priority - fallback parser
+13
View File
@@ -0,0 +1,13 @@
"""
YAML Parser for WAS Content Viewer.
"""
from .base_parser import BaseParser
class YamlParser(BaseParser):
"""YAML parser for YAML content display."""
PARSER_NAME = "yaml"
PARSER_PRIORITY = 10
+2 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "ComfyUI_Viewer"
version = "0.0.4"
description = "ComfyUI Content Viewer - Renders HTML, Markdown, and code in an iframe"
version = "0.2.0"
description = "Extensible content viewer for ComfyUI: HTML, Markdown (LaTeX, Mermaid, etc), Code, Images, and more"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT License" }
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

+497 -1533
View File
File diff suppressed because it is too large Load Diff
+545
View File
@@ -0,0 +1,545 @@
/**
* Controls Bar - UI controls for ComfyUI Viewer
* Creates the toolbar with Edit, Clear, Fullscreen, Download buttons
*/
import { computeThemeTokens } from "../utils/theme.js";
import { createZipBlob } from "../utils/zip.js";
import { detectContentType, isMultiviewContent, getMultiviewOptions, parseMultiviewContent, stripContentMarker } from "../views/view_loader.js";
const CONTROLS_HEIGHT = 32;
const LIST_SEPARATOR = "\n---LIST_SEPARATOR---\n";
/**
* Create a styled button element
* @param {object} theme - Theme tokens
* @param {string} text - Button text
* @param {string} title - Button tooltip
* @param {string} [extraStyles] - Additional CSS
* @returns {HTMLButtonElement}
*/
function createButton(theme, text, title, extraStyles = "") {
const btn = document.createElement("button");
btn.textContent = text;
btn.title = title;
btn.style.cssText = `
padding: 4px 12px;
font-size: 11px;
border: 1px solid ${theme.border};
border-radius: 4px;
background: ${theme.bg};
color: ${theme.fg};
cursor: pointer;
font-family: sans-serif;
${extraStyles}
`;
btn.onmouseenter = () => { btn.style.background = theme.accent; btn.style.color = "#fff"; };
btn.onmouseleave = () => { btn.style.background = theme.bg; btn.style.color = theme.fg; };
return btn;
}
/**
* Create view selector dropdown for multi-view content
* @param {object} theme - Theme tokens
* @param {object} node - ComfyUI node
* @param {object} elements - DOM elements container
* @param {object} callbacks - Callback functions
* @returns {HTMLSelectElement}
*/
function createViewSelector(theme, node, elements, callbacks) {
const { onViewChange } = callbacks;
const select = document.createElement("select");
select.style.cssText = `
padding: 2px 6px;
font-size: 11px;
border: 1px solid ${theme.border};
border-radius: 4px;
background: ${theme.bg};
color: ${theme.fg};
cursor: pointer;
font-family: sans-serif;
display: none;
margin-left: 8px;
`;
select.title = "Switch View";
select.onchange = () => {
const selectedView = select.value;
if (onViewChange) {
onViewChange(node, elements, selectedView);
}
};
return select;
}
/**
* Update view selector with available views
* @param {HTMLSelectElement} selector - The select element
* @param {string} content - Content to check for multi-view
* @param {string} currentView - Currently selected view name
*/
export function updateViewSelector(selector, content, currentView) {
if (!selector) return;
if (!isMultiviewContent(content)) {
selector.style.display = "none";
selector.innerHTML = "";
return;
}
const options = getMultiviewOptions(content);
if (options.length <= 1) {
selector.style.display = "none";
selector.innerHTML = "";
return;
}
// Build options
selector.innerHTML = "";
for (const opt of options) {
const option = document.createElement("option");
option.value = opt.name;
option.textContent = opt.displayName;
if (opt.name === currentView) {
option.selected = true;
}
selector.appendChild(option);
}
selector.style.display = "inline-block";
}
/**
* Create the controls bar for a viewer node
* @param {object} node - ComfyUI node
* @param {object} elements - DOM elements container
* @param {object} callbacks - Callback functions
* @param {function} callbacks.getNodeContent - Get content from node
* @param {function} callbacks.setWidgetValue - Set widget value
* @param {function} callbacks.updateIframeContent - Update iframe content
* @param {function} callbacks.buildIframeContent - Build iframe HTML
* @param {object} callbacks.app - ComfyUI app reference
* @returns {HTMLElement}
*/
export function createControlsBar(node, elements, callbacks) {
const { getNodeContent, setWidgetValue, updateIframeContent, buildIframeContent, app } = callbacks;
const theme = computeThemeTokens();
const controls = document.createElement("div");
controls.style.cssText = `
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
background: ${theme.bg};
border-bottom: 1px solid ${theme.border};
border-radius: 8px 8px 0 0;
pointer-events: auto;
box-sizing: border-box;
flex-shrink: 0;
min-height: ${CONTROLS_HEIGHT}px;
`;
const typeLabel = document.createElement("span");
typeLabel.style.cssText = `
font-size: 11px;
color: ${theme.fg};
opacity: 0.7;
font-family: sans-serif;
`;
typeLabel.textContent = "Type: detecting...";
controls.appendChild(typeLabel);
// View selector dropdown for multi-view content
const viewSelector = createViewSelector(theme, node, elements, callbacks);
controls.appendChild(viewSelector);
elements.viewSelector = viewSelector;
// Spacer to push other buttons to the right
const spacer = document.createElement("div");
spacer.style.cssText = `flex: 1;`;
controls.appendChild(spacer);
const toggleAllBtn = createToggleAllButton(theme, node, elements, callbacks);
controls.appendChild(toggleAllBtn);
elements.toggleAllBtn = toggleAllBtn;
const editBtn = createEditButton(theme, node, elements, callbacks);
controls.appendChild(editBtn);
const clearBtn = createClearButton(theme, node, elements, editBtn, callbacks);
controls.appendChild(clearBtn);
const fullscreenBtn = createFullscreenButton(theme, elements);
controls.appendChild(fullscreenBtn);
const downloadBtn = createDownloadButton(theme, node, elements, callbacks);
controls.appendChild(downloadBtn);
elements.typeLabel = typeLabel;
return controls;
}
function createToggleAllButton(theme, node, elements, callbacks) {
const { getNodeContent, updateIframeContent } = callbacks;
const toggleAllBtn = document.createElement("button");
toggleAllBtn.textContent = "☑";
toggleAllBtn.title = "Toggle All Checkboxes";
toggleAllBtn.style.cssText = `
padding: 0 6px;
font-size: 14px;
border: 1px solid ${theme.border};
border-radius: 4px;
background: ${theme.bg};
color: ${theme.fg};
cursor: pointer;
font-family: sans-serif;
display: none;
`;
toggleAllBtn.onmouseenter = () => { toggleAllBtn.style.background = theme.accent; toggleAllBtn.style.color = "#fff"; };
toggleAllBtn.onmouseleave = () => { toggleAllBtn.style.background = theme.bg; toggleAllBtn.style.color = theme.fg; };
toggleAllBtn.onclick = () => {
const content = getNodeContent(node, elements);
const metaWidget = node.widgets?.find(w => w.name === "viewer_meta");
if (!metaWidget) return;
let meta = { lastInputHash: "", excluded: [] };
try {
meta = JSON.parse(metaWidget.value || "{}");
if (!Array.isArray(meta.excluded)) meta.excluded = [];
} catch {}
const items = content.split(LIST_SEPARATOR);
const allChecked = meta.excluded.length === 0;
if (allChecked) {
meta.excluded = items.map((_, i) => i);
toggleAllBtn.textContent = "☐";
toggleAllBtn.title = "Check All";
} else {
meta.excluded = [];
toggleAllBtn.textContent = "☑";
toggleAllBtn.title = "Uncheck All";
}
metaWidget.value = JSON.stringify(meta);
elements.lastContentHash = "";
node.setDirtyCanvas?.(true, true);
updateIframeContent(node, elements);
};
return toggleAllBtn;
}
function createEditButton(theme, node, elements, callbacks) {
const { getNodeContent, setWidgetValue, updateIframeContent, app } = callbacks;
const editBtn = createButton(theme, "Edit", "Edit content");
editBtn.onclick = () => {
if (elements.isEditing) {
let newContent = "";
if (elements.listTextareas && elements.listTextareas.length > 0) {
const values = elements.listTextareas.map(ta => ta.value);
newContent = values.join(LIST_SEPARATOR);
elements.listEditContainer.style.display = "none";
elements.listTextareas = [];
} else {
newContent = elements.textarea.value;
elements.textarea.style.display = "none";
}
setWidgetValue(node, "manual_content", newContent);
elements.lastContentHash = "";
elements.isEditing = false;
elements.iframe.style.display = "block";
editBtn.textContent = "Edit";
node.setDirtyCanvas?.(true, true);
app.graph?.change?.();
updateIframeContent(node, elements);
} else {
elements.isEditing = true;
const currentContent = getNodeContent(node, elements);
if (currentContent.includes(LIST_SEPARATOR)) {
const items = currentContent.split(LIST_SEPARATOR);
if (!elements.listEditContainer && elements.contentWrapper) {
elements.listEditContainer = document.createElement("div");
elements.listEditContainer.style.cssText = `
position: absolute;
inset: 0;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
background: ${theme.bg};
`;
elements.contentWrapper.appendChild(elements.listEditContainer);
}
if (!elements.listEditContainer) return;
elements.listEditContainer.innerHTML = "";
elements.listEditContainer.style.display = "flex";
elements.listTextareas = [];
items.forEach((item, idx) => {
const itemContainer = document.createElement("div");
itemContainer.style.cssText = `
background: rgba(0,0,0,0.2);
border: 1px solid ${theme.border};
border-radius: 6px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
`;
const label = document.createElement("span");
label.textContent = `${idx + 1} / ${items.length}`;
label.style.cssText = `
background: ${theme.accent};
color: #fff;
font-size: 11px;
font-weight: bold;
padding: 2px 6px;
border-radius: 4px;
align-self: flex-start;
`;
const ta = document.createElement("textarea");
ta.value = item;
ta.style.cssText = `
width: 100%;
min-height: 80px;
resize: vertical;
background: rgba(0,0,0,0.3);
color: ${theme.fg};
border: 1px solid ${theme.border};
border-radius: 4px;
padding: 8px;
font-family: monospace;
font-size: 13px;
`;
itemContainer.appendChild(label);
itemContainer.appendChild(ta);
elements.listEditContainer.appendChild(itemContainer);
elements.listTextareas.push(ta);
});
elements.iframe.style.display = "none";
elements.textarea.style.display = "none";
} else {
elements.textarea.value = currentContent;
elements.textarea.style.display = "block";
elements.iframe.style.display = "none";
if (elements.listEditContainer) elements.listEditContainer.style.display = "none";
}
editBtn.textContent = "Save";
if (elements.listTextareas?.[0]) {
elements.listTextareas[0].focus();
} else {
elements.textarea.focus();
}
}
};
return editBtn;
}
function createClearButton(theme, node, elements, editBtn, callbacks) {
const { setWidgetValue, buildIframeContent, app } = callbacks;
const clearBtn = createButton(theme, "Clear", "Clear content");
clearBtn.onmouseenter = () => { clearBtn.style.background = "#c44"; clearBtn.style.color = "#fff"; };
clearBtn.onmouseleave = () => { clearBtn.style.background = theme.bg; clearBtn.style.color = theme.fg; };
clearBtn.onclick = () => {
setWidgetValue(node, "manual_content", "");
const contentWidget = node.widgets?.find((w) => w.name === "content");
if (contentWidget) contentWidget.value = "";
elements.lastContentHash = "";
elements.textarea.value = "";
if (elements.isEditing) {
elements.isEditing = false;
elements.textarea.style.display = "none";
elements.iframe.style.display = "block";
editBtn.textContent = "Edit";
}
node.setDirtyCanvas?.(true, true);
app.graph?.change?.();
const currentTheme = computeThemeTokens();
const emptyHtml = buildIframeContent("<p style='opacity:0.5;text-align:center;margin-top:40px;'>No content. Click Edit to add content or connect a STRING input.</p>", "html", currentTheme);
elements.iframe.srcdoc = emptyHtml;
if (elements.typeLabel) elements.typeLabel.textContent = "Type: Text";
};
return clearBtn;
}
function createFullscreenButton(theme, elements) {
const fullscreenBtn = document.createElement("button");
fullscreenBtn.textContent = "⛶";
fullscreenBtn.title = "Fullscreen";
fullscreenBtn.style.cssText = `
padding: 0 4px;
font-size: 14px;
border: 1px solid ${theme.border};
border-radius: 4px;
background: ${theme.bg};
color: ${theme.fg};
cursor: pointer;
font-family: sans-serif;
`;
fullscreenBtn.onmouseenter = () => { fullscreenBtn.style.background = theme.accent; fullscreenBtn.style.color = "#fff"; };
fullscreenBtn.onmouseleave = () => { fullscreenBtn.style.background = theme.bg; fullscreenBtn.style.color = theme.fg; };
fullscreenBtn.onclick = () => {
if (elements.iframe) {
if (elements.iframe.requestFullscreen) {
elements.iframe.requestFullscreen();
} else if (elements.iframe.webkitRequestFullscreen) {
elements.iframe.webkitRequestFullscreen();
} else if (elements.iframe.mozRequestFullScreen) {
elements.iframe.mozRequestFullScreen();
} else if (elements.iframe.msRequestFullscreen) {
elements.iframe.msRequestFullscreen();
}
}
};
return fullscreenBtn;
}
/**
* Prepare content for download by stripping markers and ensuring valid format
* @param {string} content - Raw content that may have markers
* @returns {{ content: string, ext: string }} - Cleaned content and recommended extension
*/
function prepareContentForDownload(content) {
if (!content) return { content: "", ext: "txt" };
const { content: stripped, view } = stripContentMarker(content);
// If content had a marker and the view indicates serialized data (like object view),
// the stripped content should be valid JSON - verify and format it
if (view) {
// Object view and similar serialized content types
if (view.id === "object" || view.id === "canvas") {
try {
const parsed = JSON.parse(stripped);
return {
content: JSON.stringify(parsed, null, 2),
ext: "json"
};
} catch {
// If JSON parse fails, return stripped content as-is
return { content: stripped, ext: "txt" };
}
}
}
// For non-marker content or other types, detect type and return
const contentType = detectContentType(stripped);
const extensions = {
html: "html",
svg: "svg",
markdown: "md",
python: "py",
javascript: "js",
css: "css",
text: "txt",
object: "json",
canvas: "json",
};
return {
content: stripped,
ext: extensions[contentType] || "txt"
};
}
function createDownloadButton(theme, node, elements, callbacks) {
const { getNodeContent } = callbacks;
const downloadBtn = document.createElement("button");
downloadBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" width="16" height="16">
<path fill="${theme.fg}" d="M5.625 15c0-.4142-.33579-.75-.75-.75s-.75.3358-.75.75h1.5Zm-.75 1h-.75.75Zm14.4-1c0-.4142-.3358-.75-.75-.75s-.75.3358-.75.75h1.5Zm-8.1664.5387c-.2547.3266-.1965.7979.1301 1.0527.3266.2547.7979.1965 1.0527-.1301l-1.1828-.9226Zm5.0828-4.0774c.2547-.3266.1965-.7979-.1301-1.0527-.3266-.2547-.7979-.1965-1.0527.1301l1.1828.9226Zm-5.0828 5c.2548.3266.7261.3848 1.0527.1301.3266-.2548.3848-.7261.1301-1.0527l-1.1828.9226Zm-2.71722-5.9226c-.25476-.3266-.72605-.3848-1.05265-.1301-.32661.2548-.38486.7261-.13011 1.0527l1.18276-.9226ZM10.95 16c0 .4142.3358.75.75.75s.75-.3358.75-.75h-1.5Zm1.5-11c0-.41421-.3358-.75-.75-.75s-.75.33579-.75.75h1.5ZM4.125 15v1h1.5v-1h-1.5Zm0 1c0 2.0531 1.62757 3.75 3.675 3.75v-1.5c-1.18343 0-2.175-.9893-2.175-2.25h-1.5ZM7.8 19.75h7.8v-1.5H7.8v1.5Zm7.8 0c2.0474 0 3.675-1.6969 3.675-3.75h-1.5c0 1.2607-.9916 2.25-2.175 2.25v1.5ZM19.275 16v-1h-1.5v1h1.5Zm-6.9836.4613 3.9-5-1.1828-.9226-3.9 5 1.1828.9226Zm0-.9226-3.90002-5-1.18276.9226 3.89998 5 1.1828-.9226ZM12.45 16V5h-1.5v11h1.5Z"/>
</svg>`;
downloadBtn.title = "Download";
downloadBtn.style.cssText = `
padding: 2px 4px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid ${theme.border};
border-radius: 4px;
background: ${theme.bg};
cursor: pointer;
`;
const updateDownloadSvg = (color) => {
const path = downloadBtn.querySelector("path");
if (path) path.setAttribute("fill", color);
};
downloadBtn.onmouseenter = () => { downloadBtn.style.background = theme.accent; updateDownloadSvg("#fff"); };
downloadBtn.onmouseleave = () => { downloadBtn.style.background = theme.bg; updateDownloadSvg(theme.fg); };
downloadBtn.onclick = async () => {
let content = getNodeContent(node, elements);
if (!content) {
content = elements.textarea?.value || "";
}
if (!content) {
alert("No content to download");
return;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
try {
if (content.includes(LIST_SEPARATOR)) {
const items = content.split(LIST_SEPARATOR);
const files = items.map((item, idx) => {
const prepared = prepareContentForDownload(item);
return { name: `item_${String(idx + 1).padStart(3, "0")}.${prepared.ext}`, content: prepared.content };
});
const zipBlob = await createZipBlob(files);
const url = URL.createObjectURL(zipBlob);
const a = document.createElement("a");
a.href = url;
a.download = `content_${timestamp}.zip`;
a.style.display = "none";
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
} else {
const prepared = prepareContentForDownload(content);
const filename = `content_${timestamp}.${prepared.ext}`;
const blob = new Blob([prepared.content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.style.display = "none";
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
}
} catch (e) {
console.error("[WAS Viewer] download error:", e);
}
};
return downloadBtn;
}
export { CONTROLS_HEIGHT };
+93
View File
@@ -0,0 +1,93 @@
/**
* Base styles for iframe content
* Uses CSS custom properties for theming: --theme-bg, --theme-fg, --theme-border, --theme-accent
*/
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 4px;
background: var(--theme-bg);
color: var(--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: var(--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 var(--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;
border: 1px solid var(--theme-border);
}
th, td {
border: 1px solid var(--theme-border);
padding: 8px;
text-align: left;
}
th { background: rgba(0,0,0,0.2); }
tr:last-child td { border-bottom: 1px solid var(--theme-border); }
hr {
border: none;
border-top: 1px solid var(--theme-border);
margin: 16px 0;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 16px;
margin-bottom: 8px;
color: var(--theme-fg);
}
h1:first-child, h2:first-child, h3:first-child,
h4:first-child, h5:first-child, h6:first-child {
margin-top: 0;
}
ul, ol { padding-left: 24px; margin: 8px 0; }
li { margin: 2px 0; }
+84
View File
@@ -0,0 +1,84 @@
/**
* List item styles for iframe content
* Uses CSS custom properties for theming: --theme-bg, --theme-fg, --theme-border, --theme-accent
*/
.list-item {
background: rgba(0,0,0,0.2);
border: 1px solid var(--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-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.list-checkbox {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--theme-accent);
}
.list-index {
display: inline-block;
background: var(--theme-accent);
color: #fff;
font-size: 11px;
font-weight: bold;
padding: 2px 6px;
border-radius: 4px;
}
.list-item.excluded {
opacity: 0.5;
}
.list-item.excluded .list-index {
background: #666;
}
.copy-btn {
background: transparent;
border: 1px solid var(--theme-border);
border-radius: 4px;
color: var(--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: var(--theme-accent);
color: #fff;
border-color: var(--theme-accent);
}
.copy-btn.copied {
background: #22c55e;
border-color: #22c55e;
color: #fff;
}
.list-content {
white-space: pre-wrap;
word-wrap: break-word;
}
+269
View File
@@ -0,0 +1,269 @@
/**
* Iframe Builder - Builds HTML content for the viewer iframe
*/
import { escapeHtml } from "../utils/helpers.js";
import {
detectContentType,
renderContent,
getViewStyles,
getViewScripts,
getView,
viewUsesBaseStyles
} from "../views/view_loader.js";
import { getPrismTheme, getPrismScriptTags } from "../views/code_scripts.js";
import { loadCssFile, getCssBasePath } from "../utils/css.js";
import { getFullTheme, themeToCssVars } from "../utils/theme.js";
const LIST_SEPARATOR = "\n---LIST_SEPARATOR---\n";
// Cached CSS content
let cachedBaseCss = null;
let cachedListCss = null;
/**
* Initialize CSS by loading from files
*/
async function initCss() {
if (cachedBaseCss === null) {
const basePath = getCssBasePath();
cachedBaseCss = await loadCssFile(`${basePath}/iframe-base.css`);
cachedListCss = await loadCssFile(`${basePath}/iframe-list.css`);
}
}
// Start loading CSS immediately
initCss();
/**
* Get base CSS with theme variables
* @param {object} theme - Basic theme tokens (bg, fg, border, accent)
* @returns {string} CSS styles
*/
function getBaseStyles(theme) {
// Get full theme for comprehensive CSS variables
const fullTheme = getFullTheme();
const cssVars = themeToCssVars(fullTheme);
if (cachedBaseCss) {
return cssVars + cachedBaseCss;
}
// Fallback inline styles if CSS not loaded yet
return cssVars + `
* { box-sizing: border-box; }
html, body { margin: 0; padding: 4px; background: var(--theme-bg); color: var(--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: var(--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; 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 var(--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; border: 1px solid var(--theme-border); }
th, td { border: 1px solid var(--theme-border); padding: 8px; text-align: left; }
th { background: rgba(0,0,0,0.2); }
hr { border: none; border-top: 1px solid var(--theme-border); margin: 16px 0; }
h1, h2, h3, h4, h5, h6 { margin-top: 16px; margin-bottom: 8px; color: var(--theme-fg); }
h1:first-child, h2:first-child, h3:first-child, h4:first-child, h5:first-child, h6:first-child { margin-top: 0; }
ul, ol { padding-left: 24px; margin: 8px 0; }
li { margin: 2px 0; }
`;
}
/**
* Get list CSS
* @returns {string} CSS styles
*/
function getListStyles() {
if (cachedListCss) {
return cachedListCss;
}
// Fallback inline styles if CSS not loaded yet
return `
.list-item { background: rgba(0,0,0,0.2); border: 1px solid var(--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-header-left { display: flex; align-items: center; gap: 8px; }
.list-checkbox { width: 16px; height: 16px; cursor: pointer; accent-color: var(--theme-accent); }
.list-index { display: inline-block; background: var(--theme-accent); color: #fff; font-size: 11px; font-weight: bold; padding: 2px 6px; border-radius: 4px; }
.list-item.excluded { opacity: 0.5; }
.list-item.excluded .list-index { background: #666; }
.copy-btn { background: transparent; border: 1px solid var(--theme-border); border-radius: 4px; color: var(--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: var(--theme-accent); color: #fff; border-color: var(--theme-accent); }
.copy-btn.copied { background: #22c55e; border-color: #22c55e; color: #fff; }
.list-content { white-space: pre-wrap; word-wrap: break-word; }
`;
}
/**
* Build list content HTML with checkboxes and copy buttons
* @param {string} content - Content with LIST_SEPARATOR
* @param {object} theme - Theme tokens
* @param {number[]} excluded - Excluded item indices
* @returns {string} HTML content
*/
function buildListContent(content, theme, excluded, nodeId) {
const items = content.split(LIST_SEPARATOR);
const itemsData = JSON.stringify(items);
let bodyContent = items.map((item, idx) => {
const itemType = detectContentType(item);
let itemHtml = "";
if (itemType === "html") {
itemHtml = item;
} else if (itemType === "markdown") {
itemHtml = renderContent(item, "markdown", theme);
} else {
itemHtml = `<div class="list-content">${escapeHtml(item)}</div>`;
}
const isExcluded = excluded.includes(idx);
return `<div class="list-item${isExcluded ? ' excluded' : ''}" data-idx="${idx}">
<div class="list-header">
<div class="list-header-left">
<input type="checkbox" class="list-checkbox" data-idx="${idx}" ${isExcluded ? '' : 'checked'} onchange="toggleItem(${idx}, this.checked)">
<span class="list-index">${idx + 1} / ${items.length}</span>
</div>
<button class="copy-btn" data-index="${idx}" onclick="copyItem(${idx})">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
Copy
</button>
</div>
${itemHtml}
</div>`;
}).join("");
bodyContent += `<script>
const items = ${itemsData};
function copyItem(idx) {
const text = items[idx];
navigator.clipboard.writeText(text).then(() => {
const btn = document.querySelector('[data-index="' + idx + '"]');
btn.classList.add('copied');
btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg> Copied!';
setTimeout(() => {
btn.classList.remove('copied');
btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg> Copy';
}, 1500);
});
}
function toggleItem(idx, checked) {
const item = document.querySelector('.list-item[data-idx="' + idx + '"');
if (item) item.classList.toggle('excluded', !checked);
window.parent.postMessage({ type: 'was-viewer-toggle', idx: idx, checked: checked, nodeId: '${nodeId}' }, '*');
}
<\/script>`;
return bodyContent;
}
/**
* Build complete iframe HTML content
* @param {string} content - Content to render
* @param {string} contentType - Detected content type
* @param {object} theme - Theme tokens
* @param {number[]} [excluded=[]] - Excluded item indices for list content
* @param {string|number} [nodeId=''] - Node ID for message routing
* @returns {string} Complete HTML document
*/
export function buildIframeContent(content, contentType, theme, excluded = [], nodeId = '') {
// Only include base styles if the view wants them
const useBase = viewUsesBaseStyles(contentType);
const baseStyles = useBase ? getBaseStyles(theme) : themeToCssVars(getFullTheme());
const listStyles = useBase ? getListStyles() : "";
const isListContent = content.includes(LIST_SEPARATOR);
let bodyContent = "";
if (isListContent) {
bodyContent = buildListContent(content, theme, excluded, nodeId);
} else {
const view = getView(contentType);
const htmlView = getView("html");
if (contentType === "html" && htmlView && htmlView.isFullDocument && htmlView.isFullDocument(content)) {
const hasCustomStyles = content.includes("<style") ||
content.includes("background") ||
content.includes("color:");
if (hasCustomStyles) {
return content;
}
return content.replace("</head>", `<style>${baseStyles}</style></head>`);
}
bodyContent = renderContent(content, contentType, theme);
}
const viewStyles = getViewStyles(contentType, theme);
const view = getView(contentType);
const needsPrism = view?.needsPrism?.() ||
contentType === "markdown" && (content.includes("```") || content.includes("<code"));
const prismTheme = needsPrism ? getPrismTheme(theme) : "";
const prismScripts = needsPrism ? getPrismScriptTags() : "";
let katexStyles = "";
if (contentType === "markdown") {
const mdView = getView("markdown");
if (mdView && mdView.hasLatex && mdView.hasLatex(content)) {
katexStyles = `<style>${mdView.getKatexCss?.() || ""}</style>`;
}
}
// Get inline scripts from view (for views that use getScripts() instead of getScriptData())
const viewScripts = getViewScripts(contentType, content);
// Bootstrap script for postMessage-based script injection
// This avoids embedding large scripts inline which can break HTML parsing
const bootstrapScript = `<script>
window.WAS_NODE_ID = '${nodeId || ''}';
window.WAS_SCRIPTS_LOADED = false;
window.addEventListener('message', function(event) {
if (event.data && event.data.type === 'was-inject-scripts' && !window.WAS_SCRIPTS_LOADED) {
window.WAS_SCRIPTS_LOADED = true;
var scripts = event.data.scripts || [];
var initQueue = [];
function loadNext(index) {
if (index >= scripts.length) {
// All scripts loaded, run init functions
initQueue.forEach(function(initCode) {
try { new Function(initCode)(); } catch(e) { console.error('[WAS Viewer] Init error:', e); }
});
return;
}
var scriptData = scripts[index];
try {
new Function(scriptData.code)();
if (scriptData.init) {
initQueue.push(scriptData.init);
}
loadNext(index + 1);
} catch(e) {
console.error('[WAS Viewer] Script injection error:', e);
loadNext(index + 1);
}
}
loadNext(0);
}
});
<\/script>`;
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
${bootstrapScript}
<style>${baseStyles}${listStyles}${viewStyles}${prismTheme}</style>
${katexStyles}
${prismScripts}
</head>
<body>${bodyContent}${viewScripts}</body>
</html>`;
}
export { LIST_SEPARATOR };
+73
View File
@@ -0,0 +1,73 @@
/**
* CSS Utilities - Load and inject CSS with theme variable support
*/
const loadedStyles = new Map();
/**
* Load CSS file content
* @param {string} url - URL to CSS file
* @returns {Promise<string>}
*/
export async function loadCssFile(url) {
if (loadedStyles.has(url)) {
return loadedStyles.get(url);
}
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to load ${url}`);
const css = await res.text();
loadedStyles.set(url, css);
return css;
} catch (e) {
console.error(`[WAS Viewer] Failed to load CSS: ${url}`, e);
return "";
}
}
/**
* Generate CSS custom properties from theme object
* @param {object} theme - Theme tokens { bg, fg, border, accent }
* @returns {string} CSS custom properties
*/
export function themeToCssVars(theme) {
return `
:root {
--theme-bg: ${theme.bg};
--theme-fg: ${theme.fg};
--theme-border: ${theme.border};
--theme-accent: ${theme.accent};
}
`;
}
/**
* Build CSS string with theme variables prepended
* @param {string} css - CSS content
* @param {object} theme - Theme tokens
* @returns {string}
*/
export function buildThemedCss(css, theme) {
return themeToCssVars(theme) + css;
}
/**
* Load multiple CSS files and combine with theme
* @param {string[]} urls - Array of CSS file URLs
* @param {object} theme - Theme tokens
* @returns {Promise<string>}
*/
export async function loadAndBuildCss(urls, theme) {
const cssContents = await Promise.all(urls.map(url => loadCssFile(url)));
const combinedCss = cssContents.join("\n");
return buildThemedCss(combinedCss, theme);
}
/**
* Get base path for CSS files
* @returns {string}
*/
export function getCssBasePath() {
const currentPath = import.meta.url;
return currentPath.substring(0, currentPath.lastIndexOf("/utils/")) + "/css";
}
+14
View File
@@ -0,0 +1,14 @@
/**
* General utility helpers for ComfyUI Viewer
*/
/**
* Escape HTML special characters
* @param {string} text - Text to escape
* @returns {string} Escaped HTML
*/
export function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Theme utilities for ComfyUI Viewer
* Extracts theme tokens from ComfyUI CSS variables
*/
/**
* Read a CSS variable value from a computed style
* @param {CSSStyleDeclaration} style - Computed style object
* @param {string} name - CSS variable name
* @returns {string} Variable value or empty string
*/
export function readCssVar(style, name) {
const v = style.getPropertyValue(name);
return v ? String(v).trim() : "";
}
/**
* Compute basic theme tokens from ComfyUI CSS variables
* @returns {{bg: string, fg: string, border: string, accent: string}}
*/
export 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 };
}
/**
* Extract all ComfyUI theme CSS variables for iframe injection
* Returns comprehensive theme object with all available colors
* @returns {Object} Full theme object with all color tokens
*/
export function getFullTheme() {
const rootStyle = getComputedStyle(document.documentElement);
const bodyStyle = getComputedStyle(document.body);
// Helper to get first available value
const getVar = (...names) => {
for (const name of names) {
const val = readCssVar(rootStyle, name);
if (val) return val;
}
return null;
};
return {
// Core colors
bg: getVar("--comfy-menu-bg", "--background-color", "--bg-color") || bodyStyle.backgroundColor || "#1a1a1a",
bgLight: getVar("--comfy-input-bg", "--input-bg") || "#2a2a2a",
bgDark: getVar("--comfy-menu-secondary-bg") || "#151515",
fg: getVar("--input-text", "--text-color", "--fg-color") || bodyStyle.color || "#e0e0e0",
fgMuted: getVar("--descrip-text", "--text-muted") || "#888888",
fgDisabled: getVar("--disabled-text") || "#666666",
border: getVar("--border-color", "--comfy-border-color") || "#444444",
borderLight: getVar("--tr-odd-bg-color") || "#3a3a3a",
accent: getVar("--p-button-text-primary-color", "--primary-color", "--accent-color") || "#4a9eff",
accentHover: getVar("--p-button-text-primary-hover-color") || "#5ab0ff",
accentBg: getVar("--p-highlight-background") || "#4a9eff22",
success: getVar("--success-color") || "#4caf50",
warning: getVar("--warning-color") || "#ff9800",
error: getVar("--error-color", "--error-text") || "#f44336",
// Input/Control colors
inputBg: getVar("--comfy-input-bg", "--input-bg") || "#2a2a2a",
inputBorder: getVar("--p-form-field-border-color") || "#555555",
inputFocus: getVar("--p-form-field-focus-border-color") || "#4a9eff",
// Panel/Surface colors
panelBg: getVar("--comfy-menu-bg") || "#252525",
panelHeader: getVar("--section-header-bg") || "#333333",
// Shadow
shadow: getVar("--shadow-color") || "rgba(0,0,0,0.3)",
// Scrollbar
scrollbarThumb: getVar("--comfy-scrollbar-thumb") || "#555555",
scrollbarTrack: getVar("--comfy-scrollbar-track") || "#1a1a1a",
// Selection
selection: getVar("--p-highlight-background") || "#4a9eff44",
selectionText: getVar("--p-highlight-color") || "#ffffff",
};
}
/**
* Generate CSS custom properties string from theme object
* @param {Object} theme - Theme object from getFullTheme()
* @returns {string} CSS custom properties declarations wrapped in :root
*/
export function themeToCssVars(theme) {
const vars = Object.entries(theme)
.map(([key, value]) => `--theme-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${value};`)
.join('\n ');
return `:root {\n ${vars}\n }\n `;
}
+95
View File
@@ -0,0 +1,95 @@
/**
* ZIP file creation utility for ComfyUI Viewer
* Creates ZIP blobs without external dependencies
*/
/**
* Create a ZIP blob from an array of files
* @param {Array<{name: string, content: string}>} files - Files to include
* @returns {Promise<Blob>} ZIP blob
*/
export 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" });
}
+90
View File
@@ -0,0 +1,90 @@
/**
* ANSI View - Terminal output renderer with ANSI escape code support
*/
import { BaseView, escapeHtml } from "./base_view.js";
class AnsiView extends BaseView {
static id = "ansi";
static displayName = "Terminal";
static priority = 80;
static detect(content) {
const trimmed = content.trim();
// Detect actual escape bytes
if (/\x1b\[[\d;]*m/.test(trimmed)) return 100;
// Detect literal escape sequences (text like \033[ or \x1b[)
if (/\\033\[[\d;]*m/.test(trimmed) || /\\x1b\[[\d;]*m/i.test(trimmed)) return 100;
return 0;
}
static normalizeEscapes(content) {
// Convert literal escape sequences to actual escape character
return content
.replace(/\\033/g, "\x1b")
.replace(/\\x1b/gi, "\x1b");
}
static render(content, theme) {
const ansiColors = {
"30": "#000", "31": "#e74c3c", "32": "#2ecc71", "33": "#f39c12",
"34": "#3498db", "35": "#9b59b6", "36": "#1abc9c", "37": "#ecf0f1",
"90": "#7f8c8d", "91": "#ff6b6b", "92": "#69db7c", "93": "#ffd43b",
"94": "#74c0fc", "95": "#da77f2", "96": "#63e6be", "97": "#fff",
};
const bgColors = {
"40": "#000", "41": "#e74c3c", "42": "#2ecc71", "43": "#f39c12",
"44": "#3498db", "45": "#9b59b6", "46": "#1abc9c", "47": "#ecf0f1",
};
// Normalize literal escapes to actual escape bytes first
const normalized = this.normalizeEscapes(content);
// Process ANSI codes - parse before escaping HTML
let result = '';
let lastIndex = 0;
const regex = /\x1b\[([\d;]*)m/g;
let match;
while ((match = regex.exec(normalized)) !== null) {
// Add escaped text before this match
result += escapeHtml(normalized.slice(lastIndex, match.index));
const codes = match[1];
if (!codes || codes === "0") {
result += "</span>";
} else {
const parts = codes.split(";");
let style = "";
for (const code of parts) {
if (ansiColors[code]) style += `color:${ansiColors[code]};`;
if (bgColors[code]) style += `background:${bgColors[code]};`;
if (code === "1") style += "font-weight:bold;";
if (code === "3") style += "font-style:italic;";
if (code === "4") style += "text-decoration:underline;";
}
if (style) result += `<span style="${style}">`;
}
lastIndex = regex.lastIndex;
}
// Add remaining text
result += escapeHtml(normalized.slice(lastIndex));
return `<pre class="ansi-output">${result}</pre>`;
}
static getStyles(theme) {
return `
.ansi-output {
background: #1e1e1e;
padding: 12px;
border-radius: 6px;
font-family: "Fira Code", Consolas, Monaco, monospace;
font-size: 13px;
line-height: 1.4;
}
`;
}
}
export default AnsiView;
+214
View File
@@ -0,0 +1,214 @@
/**
* Base View Interface for ComfyUI Viewer
* All view extensions should extend or follow this interface pattern.
*/
import { escapeHtml } from "../utils/helpers.js";
export { escapeHtml };
export class BaseView {
/**
* Unique identifier for this view type
* @type {string}
*/
static id = "base";
/**
* Display name for this view type
* @type {string}
*/
static displayName = "Base";
/**
* Priority for content type detection (higher = checked first)
* @type {number}
*/
static priority = 0;
/**
* Scripts loaded state
* @type {object}
*/
static scripts = {};
/**
* Check if this view can handle the given content
* @param {string} content - The content to check
* @returns {number} - Score indicating how well this view can handle the content (0 = cannot handle)
*/
static detect(content) {
return 0;
}
/**
* Load any required scripts for this view
* @param {string} basePath - Base path for loading scripts
* @returns {Promise<boolean>} - True if scripts loaded successfully
*/
static async loadScripts(basePath) {
return true;
}
/**
* Render the content to HTML
* @param {string} content - The content to render
* @param {object} theme - Theme tokens { bg, fg, border, accent }
* @returns {string} - Rendered HTML body content
*/
static render(content, theme) {
return content;
}
/**
* Get additional styles required for this view
* @param {object} theme - Theme tokens
* @returns {string} - CSS styles
*/
static getStyles(theme) {
return "";
}
/**
* Get scripts to inject into the iframe
* @returns {string} - Script tags/content
*/
static getScripts() {
return "";
}
/**
* Check if scripts are ready
* @returns {boolean}
*/
static isReady() {
return true;
}
/**
* Get message types this view handles from iframe postMessage
* @returns {string[]} - Array of message type strings
*/
static getMessageTypes() {
return [];
}
/**
* Handle a message from the iframe
* @param {string} messageType - The message type
* @param {object} data - The message data
* @param {object} node - The LiteGraph node
* @param {object} app - The ComfyUI app instance
* @returns {boolean} - True if message was handled
*/
static handleMessage(messageType, data, node, app) {
return false;
}
/**
* Get view-specific state from node widgets
* @param {object} node - The LiteGraph node
* @returns {object|null} - State object or null
*/
static getStateFromWidget(node) {
return null;
}
/**
* Inject saved state into content before rendering
* @param {string} content - Original content
* @param {object} state - State from getStateFromWidget
* @returns {string} - Modified content with state injected
*/
static injectState(content, state) {
return content;
}
/**
* Content marker prefix for this view type (if any)
* @returns {string|null}
*/
static getContentMarker() {
return null;
}
/**
* Whether this view uses the base iframe styles (iframe-base.css)
* Views that provide their own complete styling should return false
* @returns {boolean}
*/
static usesBaseStyles() {
return true;
}
}
/**
* Helper function to load a script file as text
* @param {string} url - URL to fetch
* @returns {Promise<string>}
*/
export async function loadScriptText(url) {
try {
const res = await fetch(url);
return res.ok ? await res.text() : "";
} catch (e) {
console.error(`[WAS Viewer] Failed to load script: ${url}`, e);
return "";
}
}
/**
* Custom syntax highlighter using regex rules
* Rules are applied in order; earlier rules take precedence
* @param {string} content - The content to highlight
* @param {Array<{name: string, pattern: RegExp, className: string}>} rules - Highlighting rules
* @returns {string} - HTML with syntax highlighting spans
*/
export function highlightSyntax(content, rules) {
const tokens = [];
const escaped = escapeHtml(content);
// Find all matches for all rules
for (const rule of rules) {
const regex = new RegExp(rule.pattern.source, rule.pattern.flags.includes('g') ? rule.pattern.flags : rule.pattern.flags + 'g');
let match;
while ((match = regex.exec(content)) !== null) {
tokens.push({
start: match.index,
end: match.index + match[0].length,
text: match[0],
className: rule.className,
priority: rules.indexOf(rule)
});
}
}
// Sort by start position, then by priority (lower = higher priority)
tokens.sort((a, b) => a.start - b.start || a.priority - b.priority);
// Remove overlapping tokens (keep higher priority)
const filtered = [];
let lastEnd = 0;
for (const token of tokens) {
if (token.start >= lastEnd) {
filtered.push(token);
lastEnd = token.end;
}
}
// Build highlighted output
let result = '';
let pos = 0;
for (const token of filtered) {
if (token.start > pos) {
result += escapeHtml(content.slice(pos, token.start));
}
result += `<span class="${token.className}">${escapeHtml(token.text)}</span>`;
pos = token.end;
}
if (pos < content.length) {
result += escapeHtml(content.slice(pos));
}
return result;
}
+3593
View File
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
/**
* Code Scripts Loader - Loads Prism.js for syntax highlighting
* Shared across code view modules (Python, JavaScript, CSS, YAML, Markdown)
*/
import { loadScriptText } from "./base_view.js";
import { triggerViewsRefresh } from "./view_loader.js";
const PRISM_FILES = [
"prism.min.txt",
"prism-python.min.txt",
"prism-javascript.min.txt",
"prism-css.min.txt",
"prism-markup.min.txt",
"prism-json.min.txt",
"prism-bash.min.txt",
];
let prismScripts = null;
let loadPromise = null;
/**
* Load all Prism scripts
* @param {string} basePath - Base path for scripts (web folder)
* @returns {Promise<string>}
*/
export async function loadPrismScripts(basePath) {
if (prismScripts) return prismScripts;
if (loadPromise) return loadPromise;
loadPromise = (async () => {
try {
const scripts = await Promise.all(
PRISM_FILES.map(file => loadScriptText(`${basePath}/views/code_scripts/${file}`))
);
prismScripts = scripts.join("\n");
if (prismScripts) {
triggerViewsRefresh();
}
return prismScripts;
} catch (e) {
console.error("[WAS Viewer] Failed to load Prism scripts:", e);
return "";
}
})();
return loadPromise;
}
/**
* Get loaded Prism scripts
* @returns {string|null}
*/
export function getPrismScripts() {
return prismScripts;
}
/**
* Check if Prism scripts are loaded
* @returns {boolean}
*/
export function isPrismReady() {
return prismScripts !== null && prismScripts.length > 0;
}
/**
* Get Prism theme styles
* @param {object} theme - Theme tokens
* @returns {string}
*/
export function getPrismTheme(theme) {
return `
code[class*="language-"], pre[class*="language-"] {
color: ${theme.fg};
text-shadow: none;
font-family: "Fira Code", Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 13px;
text-align: left;
white-space: pre-wrap;
word-spacing: normal;
word-break: normal;
word-wrap: break-word;
line-height: 1.5;
tab-size: 4;
hyphens: none;
}
pre[class*="language-"] {
padding: 12px;
margin: 0;
overflow: auto;
border-radius: 6px;
background: rgba(0,0,0,0.3);
}
:not(pre) > code[class*="language-"] {
padding: 2px 6px;
border-radius: 4px;
background: rgba(0,0,0,0.2);
}
.token.comment, .token.prolog, .token.doctype, .token.cdata { color: #6a9955; }
.token.punctuation { color: ${theme.fg}; }
.token.namespace { opacity: .7; }
.token.property, .token.tag, .token.boolean, .token.number, .token.constant, .token.symbol, .token.deleted { color: #b5cea8; }
.token.selector, .token.attr-name, .token.string, .token.char, .token.builtin, .token.inserted { color: #ce9178; }
.token.operator, .token.entity, .token.url, .language-css .token.string, .style .token.string { color: #d4d4d4; }
.token.atrule, .token.attr-value, .token.keyword { color: #569cd6; }
.token.function, .token.class-name { color: #dcdcaa; }
.token.regex, .token.important, .token.variable { color: #d16969; }
.token.important, .token.bold { font-weight: bold; }
.token.italic { font-style: italic; }
`;
}
/**
* Get Prism script tags for iframe injection
* @returns {string}
*/
export function getPrismScriptTags() {
if (!prismScripts) return "";
return `
<script>${prismScripts}<\/script>
<script>document.addEventListener('DOMContentLoaded', () => Prism.highlightAll());<\/script>
`;
}
+25
View File
@@ -0,0 +1,25 @@
Prism.js
========
https://prismjs.com/
MIT License
Copyright (c) 2012 Lea Verou
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+44
View File
@@ -0,0 +1,44 @@
/**
* CSS View - CSS code renderer with syntax highlighting
*/
import { BaseView, escapeHtml } from "./base_view.js";
class CssView extends BaseView {
static id = "css";
static displayName = "CSS";
static priority = 45;
static detect(content) {
const trimmed = content.trim();
let score = 0;
const cssPatterns = [
[/[.#][\w-]+\s*\{/, 2],
[/:\s*[\w-]+\s*;/, 2],
[/@media\s/, 3],
[/@import\s/, 2],
[/@keyframes\s/, 3],
[/^\s*[\w-]+:\s*[^;]+;/m, 1],
];
for (const [pattern, weight] of cssPatterns) {
if (pattern.test(trimmed)) score += weight;
}
return score;
}
static render(content, theme) {
return `<pre><code class="language-css">${escapeHtml(content)}</code></pre>`;
}
static getStyles(theme) {
return "";
}
static needsPrism() {
return true;
}
}
export default CssView;
+89
View File
@@ -0,0 +1,89 @@
/**
* CSV View - CSV table renderer
*/
import { BaseView, escapeHtml } from "./base_view.js";
class CsvView extends BaseView {
static id = "csv";
static displayName = "CSV";
static priority = 50;
static detect(content) {
const trimmed = content.trim();
const lines = trimmed.split("\n");
const csvLines = lines.filter(l => l.includes(",") && !l.includes("{") && !l.includes("<"));
if (csvLines.length >= 2) {
const firstLineCommas = (lines[0].match(/,/g) || []).length;
if (firstLineCommas >= 1) {
const consistentCommas = csvLines.filter(l => (l.match(/,/g) || []).length === firstLineCommas).length;
if (consistentCommas >= csvLines.length * 0.8) {
return 5;
}
}
}
return 0;
}
static parseRow(line) {
const result = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === "," && !inQuotes) {
result.push(current.trim());
current = "";
} else {
current += char;
}
}
result.push(current.trim());
return result;
}
static render(content, theme) {
const lines = content.trim().split("\n");
if (lines.length === 0) return `<pre>${escapeHtml(content)}</pre>`;
const headers = this.parseRow(lines[0]);
const rows = lines.slice(1).map(line => this.parseRow(line));
const thead = `<thead><tr>${headers.map(h => `<th>${escapeHtml(h)}</th>`).join("")}</tr></thead>`;
const tbody = `<tbody>${rows.map(row =>
`<tr>${row.map(cell => `<td>${escapeHtml(cell)}</td>`).join("")}</tr>`
).join("")}</tbody>`;
return `<div class="csv-container"><table class="csv-table">${thead}${tbody}</table></div>`;
}
static getStyles(theme) {
return `
.csv-container {
overflow-x: auto;
}
.csv-table {
border-collapse: collapse;
width: 100%;
font-size: 13px;
}
.csv-table th, .csv-table td {
border: 1px solid ${theme.border};
padding: 8px 12px;
text-align: left;
}
.csv-table th {
background: rgba(0,0,0,0.3);
font-weight: bold;
}
.csv-table tr:nth-child(even) {
background: rgba(0,0,0,0.1);
}
`;
}
}
export default CsvView;
+45
View File
@@ -0,0 +1,45 @@
/**
* HTML View - HTML content renderer
*/
import { BaseView } from "./base_view.js";
class HtmlView extends BaseView {
static id = "html";
static displayName = "HTML";
static priority = 100;
static detect(content) {
const trimmed = content.trim();
if (trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html") ||
(trimmed.startsWith("<") && (trimmed.includes("<head") || trimmed.includes("<body")))) {
return 100;
}
let score = 0;
const htmlTags = ["<div", "<span", "<p>", "<h1", "<h2", "<h3", "<table", "<ul", "<ol",
"<img", "<a ", "<br", "<hr", "<em>", "<strong>", "<b>", "<i>", "<code>", "<pre>"];
for (const tag of htmlTags) {
if (trimmed.includes(tag)) score += 2;
}
if (trimmed.startsWith("<") && trimmed.endsWith(">")) score += 1;
return score;
}
static render(content, theme) {
return content;
}
static isFullDocument(content) {
const trimmed = content.trim();
return trimmed.includes("<html") || trimmed.includes("<!DOCTYPE");
}
static getStyles(theme) {
return "";
}
}
export default HtmlView;
+50
View File
@@ -0,0 +1,50 @@
/**
* JavaScript View - JavaScript code renderer with syntax highlighting
*/
import { BaseView, escapeHtml } from "./base_view.js";
class JavaScriptView extends BaseView {
static id = "javascript";
static displayName = "JavaScript";
static priority = 55;
static detect(content) {
const trimmed = content.trim();
let score = 0;
const jsPatterns = [
[/^function\s+\w+\s*\(/m, 3],
[/^const\s+\w+\s*=/m, 3],
[/^let\s+\w+\s*=/m, 3],
[/^var\s+\w+\s*=/m, 2],
[/=>\s*\{/, 2],
[/^import\s+\{/m, 3],
[/^export\s+(default\s+)?/m, 3],
[/document\.\w+/, 2],
[/console\.\w+/, 2],
[/\.addEventListener\(/, 2],
[/===|!==/, 1],
[/\?\.\w+/, 2],
];
for (const [pattern, weight] of jsPatterns) {
if (pattern.test(trimmed)) score += weight;
}
return score;
}
static render(content, theme) {
return `<pre><code class="language-javascript">${escapeHtml(content)}</code></pre>`;
}
static getStyles(theme) {
return "";
}
static needsPrism() {
return true;
}
}
export default JavaScriptView;
+133
View File
@@ -0,0 +1,133 @@
/**
* JSON View - JSON tree viewer with collapsible nodes
*/
import { BaseView, escapeHtml } from "./base_view.js";
class JsonView extends BaseView {
static id = "json";
static displayName = "JSON";
static priority = 90;
static detect(content) {
const trimmed = content.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
JSON.parse(trimmed);
return 100;
} catch {}
}
return 0;
}
static renderTree(data, depth = 0) {
const indent = " ".repeat(depth);
const childIndent = " ".repeat(depth + 1);
if (data === null) return `<span class="json-null">null</span>`;
if (typeof data === "boolean") return `<span class="json-boolean">${data}</span>`;
if (typeof data === "number") return `<span class="json-number">${data}</span>`;
if (typeof data === "string") return `<span class="json-string">"${escapeHtml(data)}"</span>`;
if (Array.isArray(data)) {
if (data.length === 0) return `<span class="json-bracket">[]</span>`;
const id = `json-${Math.random().toString(36).slice(2, 9)}`;
const items = data.map((item, i) => {
const comma = i < data.length - 1 ? "," : "";
return `${childIndent}${this.renderTree(item, depth + 1)}${comma}`;
}).join("\n");
return `<span class="json-node"><span class="json-toggle" onclick="toggleJson('${id}')"><span class="json-arrow">▼</span><span class="json-bracket">[</span><span class="json-collapsed-preview">${data.length} items</span></span><span id="${id}" class="json-collapsible">
${items}
${indent}<span class="json-bracket">]</span></span><span class="json-collapsed-bracket">]</span></span>`;
}
if (typeof data === "object") {
const keys = Object.keys(data);
if (keys.length === 0) return `<span class="json-bracket">{}</span>`;
const id = `json-${Math.random().toString(36).slice(2, 9)}`;
const items = keys.map((key, i) => {
const comma = i < keys.length - 1 ? "," : "";
return `${childIndent}<span class="json-key">"${escapeHtml(key)}"</span>: ${this.renderTree(data[key], depth + 1)}${comma}`;
}).join("\n");
return `<span class="json-node"><span class="json-toggle" onclick="toggleJson('${id}')"><span class="json-arrow">▼</span><span class="json-bracket">{</span><span class="json-collapsed-preview">${keys.length} keys</span></span><span id="${id}" class="json-collapsible">
${items}
${indent}<span class="json-bracket">}</span></span><span class="json-collapsed-bracket">}</span></span>`;
}
return escapeHtml(String(data));
}
static render(content, theme) {
try {
const parsed = JSON.parse(content);
return `<div class="json-tree"><pre>${this.renderTree(parsed)}</pre></div>`;
} catch {
return `<pre><code class="language-javascript">${escapeHtml(content)}</code></pre>`;
}
}
static getStyles(theme) {
return `
.json-tree pre {
margin: 0;
white-space: pre-wrap;
font-size: 13px;
line-height: 1.4;
}
.json-key { color: #9cdcfe; }
.json-string { color: #ce9178; }
.json-number { color: #b5cea8; }
.json-boolean { color: #569cd6; }
.json-null { color: #569cd6; }
.json-bracket { color: ${theme.fg}; }
.json-node { display: inline; }
.json-toggle {
cursor: pointer;
user-select: none;
}
.json-toggle:hover { opacity: 0.7; }
.json-arrow {
display: inline-block;
width: 1em;
font-size: 10px;
transition: transform 0.15s;
color: #888;
}
.json-collapsed-preview {
color: #6a9955;
font-size: 11px;
font-style: italic;
display: none;
}
.json-collapsed-bracket {
display: none;
}
.json-collapsible.collapsed {
display: none;
}
.json-collapsible.collapsed + .json-collapsed-bracket {
display: inline;
}
.json-node:has(> .json-collapsible.collapsed) .json-collapsed-preview {
display: inline;
margin-left: 4px;
}
.json-node:has(> .json-collapsible.collapsed) .json-arrow {
transform: rotate(-90deg);
}
`;
}
static getScripts() {
return `
<script>
function toggleJson(id) {
const el = document.getElementById(id);
if (el) el.classList.toggle('collapsed');
}
<\/script>
`;
}
}
export default JsonView;
+342
View File
@@ -0,0 +1,342 @@
/**
* Markdown View - Markdown renderer with Mermaid diagrams and KaTeX math support
*/
import { BaseView, escapeHtml, loadScriptText } from "./base_view.js";
import { triggerViewsRefresh } from "./view_loader.js";
const MERMAID_FILE = "mermaid.min.txt";
const KATEX_FILES = ["katex.min.txt", "katex-auto-render.min.txt"];
const KATEX_CSS_FILE = "katex-with-fonts.min.css.txt";
class MarkdownView extends BaseView {
static id = "markdown";
static displayName = "Markdown";
static priority = 70;
static scripts = {
mermaid: null,
katexCore: null,
katexAutoRender: null,
katexCss: null,
};
static detect(content) {
const trimmed = content.trim();
let score = 0;
const mdPatterns = [
[/^#{1,6}\s+.+/m, 3],
[/^\s*[-*+]\s+.+/m, 1],
[/^\s*\d+\.\s+.+/m, 1],
[/\[[^\]]+\]\([^)]+\)/, 2],
[/^\s*```/m, 3],
[/^\s*>/m, 1],
[/\*\*[^*]+\*\*/, 1],
[/~~[^~]+~~/, 1],
[/^\s*\|.+\|.+\|/m, 2],
[/!\[[^\]]*\]\([^)]+\)/, 2],
];
for (const [pattern, weight] of mdPatterns) {
if (pattern.test(trimmed)) score += weight;
}
return score;
}
static async loadScripts(basePath) {
const promises = [];
if (!this.scripts.mermaid) {
promises.push(
loadScriptText(`${basePath}/views/markdown_scripts/${MERMAID_FILE}`)
.then(script => {
if (script && script.trim().startsWith('<')) {
console.warn('[Markdown] Mermaid script appears to be HTML, not JS - likely 404');
this.scripts.mermaid = null;
} else {
this.scripts.mermaid = script;
if (script) triggerViewsRefresh();
}
})
);
}
if (!this.scripts.katexCore || !this.scripts.katexAutoRender || !this.scripts.katexCss) {
promises.push(
Promise.all([
loadScriptText(`${basePath}/views/markdown_scripts/${KATEX_FILES[0]}`),
loadScriptText(`${basePath}/views/markdown_scripts/${KATEX_FILES[1]}`),
loadScriptText(`${basePath}/views/markdown_scripts/${KATEX_CSS_FILE}`),
]).then(([katexCore, autoRender, katexCss]) => {
// Validate scripts aren't HTML error pages
this.scripts.katexCore = (katexCore && !katexCore.trim().startsWith('<')) ? katexCore : null;
this.scripts.katexAutoRender = (autoRender && !autoRender.trim().startsWith('<')) ? autoRender : null;
this.scripts.katexCss = katexCss; // CSS can start with < in comments
if (this.scripts.katexCore && this.scripts.katexCss) triggerViewsRefresh();
})
);
}
await Promise.all(promises);
return true;
}
static isReady() {
return true;
}
static parseNestedList(match, listTag, itemTag) {
const regex = new RegExp(`<${itemTag}[^>]*data-level="(\\d+)"[^>]*(?:class="([^"]*)")?[^>]*>(.*?)<\\/${itemTag}>`, 'g');
const items = [];
let m;
while ((m = regex.exec(match)) !== null) {
items.push({ level: parseInt(m[1]), className: m[2] || '', content: m[3] });
}
if (items.length === 0) return match;
let result = '';
let stack = [];
for (const item of items) {
while (stack.length > 0 && stack[stack.length - 1] > item.level) {
result += `</li></${listTag}>`;
stack.pop();
}
if (stack.length === 0 || stack[stack.length - 1] < item.level) {
result += `<${listTag}>`;
stack.push(item.level);
} else if (stack[stack.length - 1] === item.level && result.length > 0) {
result += '</li>';
}
const classAttr = item.className ? ` class="${item.className}"` : '';
result += `<li${classAttr}>${item.content}`;
}
while (stack.length > 0) {
result += `</li></${listTag}>`;
stack.pop();
}
return result;
}
static parseMarkdown(md) {
let html = md;
// STEP 1: Extract and protect code blocks FIRST (before any other processing)
// This prevents LaTeX, emphasis, etc. from being parsed inside code
const codeBlocks = [];
const PLACEHOLDER = '\x00CODE_BLOCK_';
// Fenced code blocks (mermaid)
html = html.replace(/^```(?:mermaid|flow|flex)(?:\[([^\]]*)\])?\n([\s\S]*?)```$/gm, (_, options, code) => {
const centered = options && options.toLowerCase().includes('center');
const style = centered ? 'text-align:center;' : 'text-align:left;';
const rendered = `<div class="mermaid" style="${style}">${code.trim()}</div>`;
codeBlocks.push(rendered);
return `${PLACEHOLDER}${codeBlocks.length - 1}\x00`;
});
// Fenced code blocks (regular)
html = html.replace(/^```(\w*)\n([\s\S]*?)```$/gm, (_, lang, code) => {
const rendered = `<pre><code class="language-${lang || "text"}">${escapeHtml(code.trim())}</code></pre>`;
codeBlocks.push(rendered);
return `${PLACEHOLDER}${codeBlocks.length - 1}\x00`;
});
// Inline code
html = html.replace(/`([^`]+)`/g, (_, code) => {
const rendered = `<code>${escapeHtml(code)}</code>`;
codeBlocks.push(rendered);
return `${PLACEHOLDER}${codeBlocks.length - 1}\x00`;
});
// STEP 2: Now process LaTeX (code is already protected)
html = html.replace(/\$\$\s*\\\(([^]*?)\\\)\s*\$\$/g, '$$$$$1$$$$');
html = html.replace(/\$\$\s*\\\[([^]*?)\\\]\s*\$\$/g, '$$$$$1$$$$');
html = html.replace(/([^\n$\x00])\$\$([^$\n]+)\$\$([^\n$])/g, '$1$$$2$$$3');
html = html.replace(/^(.+)\n=+\s*$/gm, "<h1>$1</h1>");
html = html.replace(/^(.+)\n-+\s*$/gm, "<h2>$1</h2>");
html = html.replace(/^######\s*(.+)$/gm, "<h6>$1</h6>");
html = html.replace(/^#####\s*(.+)$/gm, "<h5>$1</h5>");
html = html.replace(/^####\s*(.+)$/gm, "<h4>$1</h4>");
html = html.replace(/^###\s*(.+)$/gm, "<h3>$1</h3>");
html = html.replace(/^##\s*(.+)$/gm, "<h2>$1</h2>");
html = html.replace(/^#\s*(.+)$/gm, "<h1>$1</h1>");
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;">');
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
html = html.replace(/__(.+?)__/g, "<strong>$1</strong>");
html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
html = html.replace(/(?<![a-zA-Z0-9\/_])_([^_\s][^_]*)_(?![a-zA-Z0-9\/_])/g, "<em>$1</em>");
html = html.replace(/~~(.+?)~~/g, "<del>$1</del>");
html = html.replace(/^>\s+(.*)$/gm, "<blockquote>$1</blockquote>");
html = html.replace(/^(\s*)- \[x\]\s+(.*)$/gim, (_, indent, text) => {
const level = Math.floor(indent.length / 2);
return `<uli data-level="${level}" class="task-item checked"><input type="checkbox" checked disabled> ${text}</uli>`;
});
html = html.replace(/^(\s*)- \[ \]\s+(.*)$/gm, (_, indent, text) => {
const level = Math.floor(indent.length / 2);
return `<uli data-level="${level}" class="task-item"><input type="checkbox" disabled> ${text}</uli>`;
});
html = html.replace(/^(\s*)\d+\.\s+(.*)$/gm, (_, indent, text) => {
const level = Math.floor(indent.length / 2);
return `<oli data-level="${level}">${text}</oli>`;
});
html = html.replace(/^(\s*)[-*+]\s+(.*)$/gm, (_, indent, text) => {
const level = Math.floor(indent.length / 2);
return `<uli data-level="${level}">${text}</uli>`;
});
html = html.replace(/(<oli[^>]*>.*?<\/oli>\s*)+/g, (match) => {
return this.parseNestedList(match, 'ol', 'oli');
});
html = html.replace(/(<uli[^>]*>.*?<\/uli>\s*)+/g, (match) => {
return this.parseNestedList(match, 'ul', 'uli');
});
html = html.replace(/^---$/gm, "<hr>");
html = html.replace(/^\*\*\*$/gm, "<hr>");
html = html.replace(/^(\|.+\|)\n(\|[-:\s|]+\|)\n((?:\|.+\|\n?)+)/gm, (match, headerRow, separatorRow, bodyRows) => {
const parseRow = (row, cellTag) => {
const cells = row.split("|").slice(1, -1);
return `<tr>${cells.map(c => `<${cellTag}>${c.trim()}</${cellTag}>`).join("")}</tr>`;
};
const alignments = separatorRow.split("|").slice(1, -1).map(cell => {
const trimmed = cell.trim();
if (trimmed.startsWith(":") && trimmed.endsWith(":")) return "center";
if (trimmed.endsWith(":")) return "right";
return "left";
});
const headerCells = headerRow.split("|").slice(1, -1);
const theadRow = `<tr>${headerCells.map((c, i) => `<th style="text-align:${alignments[i] || "left"}">${c.trim()}</th>`).join("")}</tr>`;
const bodyRowsArray = bodyRows.trim().split("\n");
const tbodyRows = bodyRowsArray.map(row => {
const cells = row.split("|").slice(1, -1);
return `<tr>${cells.map((c, i) => `<td style="text-align:${alignments[i] || "left"}">${c.trim()}</td>`).join("")}</tr>`;
}).join("");
return `<table><thead>${theadRow}</thead><tbody>${tbodyRows}</tbody></table>`;
});
html = html.replace(/\n\n/g, "</p><p>");
html = "<p>" + html + "</p>";
// STEP 3: Restore protected code blocks BEFORE paragraph cleanup
// (so <pre> tags can be properly unwrapped from <p> tags)
html = html.replace(/\x00CODE_BLOCK_(\d+)\x00/g, (_, idx) => codeBlocks[parseInt(idx)] || '');
// STEP 4: Clean up paragraph tags around block elements
html = html.replace(/<p>\s*<(h[1-6]|ul|ol|pre|blockquote|hr|table|div)/g, "<$1");
html = html.replace(/<\/(h[1-6]|ul|ol|pre|blockquote|table|div)>\s*<\/p>/g, "</$1>");
html = html.replace(/<p>\s*<\/p>/g, "");
return html;
}
static render(content, theme) {
return this.parseMarkdown(content);
}
static getStyles(theme) {
return `
.mermaid {
background: transparent;
text-align: center;
padding: 16px;
}
.math-block {
text-align: center;
margin: 16px 0;
overflow-x: auto;
}
.task-item {
list-style: none;
margin-left: -20px;
}
.task-item input[type="checkbox"] {
margin-right: 8px;
accent-color: ${theme.accent};
}
.task-item.checked {
color: ${theme.fg};
}
`;
}
static hasMermaid(content) {
return /```(?:mermaid|flow|flex)/.test(content) || content.includes("<div class=\"mermaid\">");
}
static hasLatex(content) {
return content.includes("$$") || content.includes("\\(") || content.includes("\\[") || /\$[^$\n]+\$/.test(content);
}
static getScripts(content) {
// Legacy method - returns empty, use getScriptData instead for postMessage injection
return "";
}
static getScriptData(content) {
const scripts = [];
const hasMermaid = this.hasMermaid(content);
// Only inject mermaid if it's valid JS (not an HTML error page)
if (hasMermaid && this.scripts.mermaid && !this.scripts.mermaid.trim().startsWith('<')) {
scripts.push({
code: this.scripts.mermaid,
init: `mermaid.initialize({ startOnLoad: true, theme: 'dark' });`
});
}
const hasLatex = this.hasLatex(content);
// Only inject KaTeX if it's valid JS (not an HTML error page)
const katexCoreValid = this.scripts.katexCore && !this.scripts.katexCore.trim().startsWith('<');
const katexAutoRenderValid = this.scripts.katexAutoRender && !this.scripts.katexAutoRender.trim().startsWith('<');
if (hasLatex && katexCoreValid && katexAutoRenderValid) {
scripts.push({
code: this.scripts.katexCore
});
scripts.push({
code: this.scripts.katexAutoRender,
init: `if (typeof renderMathInElement === 'function') {
renderMathInElement(document.body, {
delimiters: [
{left: '$$', right: '$$', display: true},
{left: '$', right: '$', display: false},
{left: '\\\\(', right: '\\\\)', display: false},
{left: '\\\\[', right: '\\\\]', display: true}
],
ignoredTags: ['script', 'noscript', 'style', 'textarea', 'option', 'code', 'pre'],
throwOnError: false
});
}`
});
}
return scripts;
}
static getKatexCss() {
return this.scripts.katexCss || "";
}
static needsPrism() {
return true;
}
}
export default MarkdownView;
+57
View File
@@ -0,0 +1,57 @@
This directory contains third-party libraries with their respective licenses:
================================================================================
KaTeX
================================================================================
https://katex.org/
Files: katex.min.txt, katex-auto-render.min.txt, katex-with-fonts.min.css.txt
MIT License
Copyright (c) 2013-2020 Khan Academy and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
Mermaid
================================================================================
https://mermaid.js.org/
Files: mermaid.min.txt
MIT License
Copyright (c) 2014 - 2022 Knut Sveidqvist
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+840
View File
@@ -0,0 +1,840 @@
/**
* Object View - Generic Python object introspection viewer
* Displays metrics, spectral graphs, and serialized data for:
* - PyTorch/NumPy tensors
* - PIL Images
* - SafeTensors models
* - Generic Python objects
*/
import { BaseView, escapeHtml } from "./base_view.js";
class ObjectView extends BaseView {
static id = "object";
static displayName = "Object";
static priority = 5;
static OBJECT_MARKER = "$WAS_OBJECT$";
static detect(content) {
try {
let jsonContent = content;
if (content.startsWith(this.OBJECT_MARKER)) {
jsonContent = content.slice(this.OBJECT_MARKER.length);
}
const parsed = JSON.parse(jsonContent);
if (parsed.type === "object_viewer" && Array.isArray(parsed.objects)) {
return 150;
}
} catch {}
return 0;
}
static getContentMarker() {
return this.OBJECT_MARKER;
}
static render(content, theme) {
let data;
try {
let jsonContent = content;
if (content.startsWith(this.OBJECT_MARKER)) {
jsonContent = content.slice(this.OBJECT_MARKER.length);
}
data = JSON.parse(jsonContent);
} catch {
return `<pre>Invalid object data</pre>`;
}
const objects = data.objects || [];
if (objects.length === 0) {
return `<div class="object-viewer"><p>No objects to display</p></div>`;
}
// Include inline scripts for toggle functions and histogram drawing
let html = `
<script>
function toggleObjectSection(id) {
var el = document.getElementById(id);
if (el) el.classList.toggle('collapsed');
}
function toggleSection(id) {
var el = document.getElementById(id);
if (el) el.classList.toggle('collapsed');
}
function drawHistograms() {
var canvases = document.querySelectorAll('.histogram-canvas');
canvases.forEach(function(canvas) {
var ctx = canvas.getContext('2d');
var data = JSON.parse(canvas.dataset.histogram || '[]');
var color = canvas.dataset.color || '#888888';
if (!data || data.length === 0) return;
var w = canvas.width;
var h = canvas.height;
var max = Math.max.apply(null, data);
var barWidth = w / data.length;
ctx.clearRect(0, 0, w, h);
// Draw bars
ctx.fillStyle = color;
ctx.globalAlpha = 0.7;
for (var i = 0; i < data.length; i++) {
var barHeight = (data[i] / max) * h;
ctx.fillRect(i * barWidth, h - barHeight, barWidth - 0.5, barHeight);
}
// Draw outline
ctx.globalAlpha = 1;
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, h);
for (var i = 0; i < data.length; i++) {
var barHeight = (data[i] / max) * h;
ctx.lineTo(i * barWidth + barWidth / 2, h - barHeight);
}
ctx.lineTo(w, h);
ctx.stroke();
});
}
// Draw histograms when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', drawHistograms);
} else {
setTimeout(drawHistograms, 0);
}
<\/script>
<div class="object-viewer">`;
for (const obj of objects) {
html += this.renderObject(obj);
}
html += `</div>`;
return html;
}
static renderObject(obj) {
const id = `obj-${Math.random().toString(36).slice(2, 9)}`;
const categoryIcon = this.getCategoryIcon(obj.category);
const categoryClass = `category-${obj.category}`;
let html = `
<div class="object-card ${categoryClass}">
<div class="object-header" onclick="toggleObjectSection('${id}')">
<span class="object-icon">${categoryIcon}</span>
<span class="object-type">${escapeHtml(obj.full_type || obj.type_name)}</span>
<span class="object-category">${escapeHtml(obj.category)}</span>
<span class="object-toggle">▼</span>
</div>
<div class="object-content" id="${id}">
`;
// Metrics Section
if (obj.metrics && Object.keys(obj.metrics).length > 0) {
html += this.renderMetrics(obj.metrics, obj.category);
}
// Spectral/Histogram Section
if (obj.spectral) {
html += this.renderSpectral(obj.spectral);
}
// Attributes Section (for generic objects)
if (obj.attributes && Object.keys(obj.attributes).length > 0) {
html += this.renderAttributes(obj.attributes);
}
// Source Info Section
if (obj.source_info) {
html += this.renderSourceInfo(obj.source_info);
}
// Serialized Data Section
if (obj.serialized) {
html += this.renderSerialized(obj.serialized);
}
html += `
</div>
</div>
`;
return html;
}
static getCategoryIcon(category) {
const icons = {
tensor: "🔢",
numpy: "📊",
pil_image: "🖼️",
safetensors: "📦",
dict: "📋",
list: "📝",
object: "🔮",
};
return icons[category] || "❓";
}
static renderMetrics(metrics, category) {
const id = `metrics-${Math.random().toString(36).slice(2, 9)}`;
let html = `
<div class="object-section">
<div class="section-header" onclick="toggleSection('${id}')">
<span class="section-title">📊 Metrics</span>
<span class="section-toggle">▼</span>
</div>
<div class="section-content" id="${id}">
<div class="metrics-grid">
`;
// Render based on category for optimal display
if (category === "tensor" || category === "numpy") {
html += this.renderTensorMetrics(metrics);
} else if (category === "pil_image") {
html += this.renderImageMetrics(metrics);
} else if (category === "safetensors") {
html += this.renderSafetensorsMetrics(metrics);
} else {
html += this.renderGenericMetrics(metrics);
}
html += `
</div>
</div>
</div>
`;
return html;
}
static renderTensorMetrics(metrics) {
let html = "";
// Primary info row
if (metrics.shape) {
html += `<div class="metric-item highlight"><span class="metric-label">Shape</span><span class="metric-value">[${metrics.shape.join(", ")}]</span></div>`;
}
if (metrics.dtype) {
html += `<div class="metric-item"><span class="metric-label">DType</span><span class="metric-value dtype">${escapeHtml(metrics.dtype)}</span></div>`;
}
if (metrics.resolution) {
html += `<div class="metric-item highlight"><span class="metric-label">Resolution</span><span class="metric-value">${escapeHtml(metrics.resolution)}</span></div>`;
}
if (metrics.channels) {
html += `<div class="metric-item"><span class="metric-label">Channels</span><span class="metric-value">${metrics.channels}</span></div>`;
}
if (metrics.image_format) {
html += `<div class="metric-item"><span class="metric-label">Format</span><span class="metric-value">${escapeHtml(metrics.image_format)}</span></div>`;
}
if (metrics.memory_human) {
html += `<div class="metric-item"><span class="metric-label">Memory</span><span class="metric-value">${escapeHtml(metrics.memory_human)}</span></div>`;
}
if (metrics.device) {
html += `<div class="metric-item"><span class="metric-label">Device</span><span class="metric-value device">${escapeHtml(metrics.device)}</span></div>`;
}
if (metrics.numel) {
html += `<div class="metric-item"><span class="metric-label">Elements</span><span class="metric-value">${metrics.numel.toLocaleString()}</span></div>`;
}
// Statistics
if (metrics.stats) {
html += `<div class="metric-divider"></div>`;
html += `<div class="metric-item stat"><span class="metric-label">Min</span><span class="metric-value">${this.formatNumber(metrics.stats.min)}</span></div>`;
html += `<div class="metric-item stat"><span class="metric-label">Max</span><span class="metric-value">${this.formatNumber(metrics.stats.max)}</span></div>`;
html += `<div class="metric-item stat"><span class="metric-label">Mean</span><span class="metric-value">${this.formatNumber(metrics.stats.mean)}</span></div>`;
html += `<div class="metric-item stat"><span class="metric-label">Std</span><span class="metric-value">${this.formatNumber(metrics.stats.std)}</span></div>`;
}
return html;
}
static renderImageMetrics(metrics) {
let html = "";
if (metrics.resolution) {
html += `<div class="metric-item highlight"><span class="metric-label">Resolution</span><span class="metric-value">${escapeHtml(metrics.resolution)}</span></div>`;
}
if (metrics.mode) {
html += `<div class="metric-item"><span class="metric-label">Mode</span><span class="metric-value">${escapeHtml(metrics.mode)}</span></div>`;
}
if (metrics.channels) {
html += `<div class="metric-item"><span class="metric-label">Channels</span><span class="metric-value">${metrics.channels}</span></div>`;
}
if (metrics.bands) {
html += `<div class="metric-item"><span class="metric-label">Bands</span><span class="metric-value">${metrics.bands.join(", ")}</span></div>`;
}
if (metrics.format) {
html += `<div class="metric-item"><span class="metric-label">Format</span><span class="metric-value">${escapeHtml(metrics.format || "N/A")}</span></div>`;
}
if (metrics.memory_human) {
html += `<div class="metric-item"><span class="metric-label">Memory</span><span class="metric-value">${escapeHtml(metrics.memory_human)}</span></div>`;
}
if (metrics.dpi) {
html += `<div class="metric-item"><span class="metric-label">DPI</span><span class="metric-value">${metrics.dpi}</span></div>`;
}
if (metrics.has_icc_profile) {
html += `<div class="metric-item"><span class="metric-label">ICC Profile</span><span class="metric-value">✓</span></div>`;
}
if (metrics.has_exif) {
html += `<div class="metric-item"><span class="metric-label">EXIF</span><span class="metric-value">✓</span></div>`;
}
// Statistics
if (metrics.stats) {
html += `<div class="metric-divider"></div>`;
html += `<div class="metric-item stat"><span class="metric-label">Min</span><span class="metric-value">${metrics.stats.min}</span></div>`;
html += `<div class="metric-item stat"><span class="metric-label">Max</span><span class="metric-value">${metrics.stats.max}</span></div>`;
html += `<div class="metric-item stat"><span class="metric-label">Mean</span><span class="metric-value">${this.formatNumber(metrics.stats.mean)}</span></div>`;
html += `<div class="metric-item stat"><span class="metric-label">Std</span><span class="metric-value">${this.formatNumber(metrics.stats.std)}</span></div>`;
}
return html;
}
static renderSafetensorsMetrics(metrics) {
let html = "";
if (metrics.tensor_count !== undefined) {
html += `<div class="metric-item highlight"><span class="metric-label">Tensors</span><span class="metric-value">${metrics.tensor_count.toLocaleString()}</span></div>`;
}
if (metrics.total_params_human) {
html += `<div class="metric-item highlight"><span class="metric-label">Parameters</span><span class="metric-value">${escapeHtml(metrics.total_params_human)}</span></div>`;
}
// Tensor info table
if (metrics.tensor_info && metrics.tensor_info.length > 0) {
html += `<div class="metric-divider"></div>`;
html += `<div class="tensor-table-wrapper">`;
html += `<table class="tensor-table">`;
html += `<thead><tr><th>Name</th><th>Shape</th><th>DType</th><th>Params</th></tr></thead>`;
html += `<tbody>`;
for (const tensor of metrics.tensor_info) {
if (tensor.truncated) {
html += `<tr class="truncated-row"><td colspan="4">${escapeHtml(tensor.name)}</td></tr>`;
} else if (tensor.error) {
html += `<tr class="error-row"><td>${escapeHtml(tensor.name)}</td><td colspan="3" class="error">${escapeHtml(tensor.error)}</td></tr>`;
} else {
const shape = tensor.shape ? `[${tensor.shape.join(", ")}]` : "?";
const params = tensor.params ? tensor.params.toLocaleString() : "?";
html += `<tr><td class="tensor-name">${escapeHtml(tensor.name)}</td><td>${shape}</td><td>${escapeHtml(tensor.dtype || "?")}</td><td>${params}</td></tr>`;
}
}
html += `</tbody></table>`;
html += `</div>`;
}
return html;
}
static renderGenericMetrics(metrics) {
let html = "";
for (const [key, value] of Object.entries(metrics)) {
if (key === "error") continue;
let displayValue;
if (Array.isArray(value)) {
displayValue = value.slice(0, 10).join(", ");
if (value.length > 10) displayValue += "...";
} else if (typeof value === "object" && value !== null) {
displayValue = JSON.stringify(value).slice(0, 100);
} else {
displayValue = String(value);
}
html += `<div class="metric-item"><span class="metric-label">${escapeHtml(this.formatLabel(key))}</span><span class="metric-value">${escapeHtml(displayValue)}</span></div>`;
}
return html;
}
static renderSpectral(spectral) {
const id = `spectral-${Math.random().toString(36).slice(2, 9)}`;
let html = `
<div class="object-section spectral-section">
<div class="section-header" onclick="toggleSection('${id}')">
<span class="section-title">📈 ${spectral.type === "spectral" ? "Channel Histograms" : "Histogram"}</span>
<span class="section-toggle">▼</span>
</div>
<div class="section-content" id="${id}">
`;
if (spectral.type === "spectral" && spectral.channels) {
// Multi-channel spectral view
html += `<div class="spectral-container">`;
for (const channel of spectral.channels) {
const canvasId = `canvas-${Math.random().toString(36).slice(2, 9)}`;
const color = this.getChannelColor(channel.name);
html += `
<div class="channel-histogram">
<div class="channel-label" style="color: ${color}">${escapeHtml(channel.name)}</div>
<canvas id="${canvasId}" class="histogram-canvas" width="200" height="60"
data-histogram='${JSON.stringify(channel.histogram)}'
data-color="${color}"></canvas>
</div>
`;
}
html += `</div>`;
} else if (spectral.data) {
// Single histogram
const canvasId = `canvas-${Math.random().toString(36).slice(2, 9)}`;
html += `
<div class="single-histogram">
<canvas id="${canvasId}" class="histogram-canvas wide" width="400" height="80"
data-histogram='${JSON.stringify(spectral.data)}'
data-color="#888888"></canvas>
</div>
`;
}
html += `
</div>
</div>
`;
return html;
}
static getChannelColor(name) {
const colors = {
R: "#ff4444",
G: "#44ff44",
B: "#4488ff",
A: "#ffffff",
L: "#888888",
};
return colors[name] || "#888888";
}
static renderAttributes(attributes) {
const id = `attrs-${Math.random().toString(36).slice(2, 9)}`;
let html = `
<div class="object-section">
<div class="section-header" onclick="toggleSection('${id}')">
<span class="section-title">🏷️ Attributes (${Object.keys(attributes).length})</span>
<span class="section-toggle">▼</span>
</div>
<div class="section-content collapsed" id="${id}">
<table class="attributes-table">
<thead><tr><th>Name</th><th>Type</th><th>Value</th></tr></thead>
<tbody>
`;
for (const [name, info] of Object.entries(attributes)) {
html += `<tr>
<td class="attr-name">${escapeHtml(name)}</td>
<td class="attr-type">${escapeHtml(info.type)}</td>
<td class="attr-value">${escapeHtml(String(info.value))}</td>
</tr>`;
}
html += `
</tbody>
</table>
</div>
</div>
`;
return html;
}
static renderSourceInfo(sourceInfo) {
const id = `source-${Math.random().toString(36).slice(2, 9)}`;
let html = `
<div class="object-section">
<div class="section-header" onclick="toggleSection('${id}')">
<span class="section-title">📁 Source Info</span>
<span class="section-toggle">▼</span>
</div>
<div class="section-content collapsed" id="${id}">
<div class="source-info">
`;
if (sourceInfo.file) {
html += `<div class="source-item"><span class="source-label">File:</span><span class="source-value path">${escapeHtml(sourceInfo.file)}</span></div>`;
}
if (sourceInfo.source_lines) {
html += `<div class="source-item"><span class="source-label">Lines:</span><span class="source-value">${sourceInfo.source_lines}</span></div>`;
}
if (sourceInfo.bases && sourceInfo.bases.length > 0) {
html += `<div class="source-item"><span class="source-label">Bases:</span><span class="source-value">${sourceInfo.bases.join(" → ")}</span></div>`;
}
html += `
</div>
</div>
</div>
`;
return html;
}
static renderSerialized(serialized) {
const id = `serial-${Math.random().toString(36).slice(2, 9)}`;
let displayContent;
try {
const parsed = JSON.parse(serialized);
displayContent = JSON.stringify(parsed, null, 2);
} catch {
displayContent = serialized;
}
// Truncate if too long
const maxLen = 5000;
let truncated = false;
if (displayContent.length > maxLen) {
displayContent = displayContent.slice(0, maxLen);
truncated = true;
}
return `
<div class="object-section">
<div class="section-header" onclick="toggleSection('${id}')">
<span class="section-title">📄 Serialized Data${truncated ? " (truncated)" : ""}</span>
<span class="section-toggle">▼</span>
</div>
<div class="section-content collapsed" id="${id}">
<pre class="serialized-content">${escapeHtml(displayContent)}${truncated ? "\n\n... (truncated)" : ""}</pre>
</div>
</div>
`;
}
static formatNumber(num) {
if (typeof num !== "number") return String(num);
if (Number.isInteger(num)) return num.toLocaleString();
if (Math.abs(num) < 0.001 || Math.abs(num) > 100000) {
return num.toExponential(3);
}
return num.toFixed(4);
}
static formatLabel(key) {
return key
.replace(/_/g, " ")
.replace(/([A-Z])/g, " $1")
.trim()
.split(" ")
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
static getStyles(theme) {
return `
.object-viewer {
padding: 12px;
font-family: system-ui, -apple-system, sans-serif;
font-size: 13px;
line-height: 1.4;
}
.object-card {
background: ${theme.bg};
border: 1px solid ${theme.border};
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
}
.object-card.category-tensor { border-left: 3px solid #ff9800; }
.object-card.category-numpy { border-left: 3px solid #2196f3; }
.object-card.category-pil_image { border-left: 3px solid #4caf50; }
.object-card.category-safetensors { border-left: 3px solid #9c27b0; }
.object-card.category-dict { border-left: 3px solid #607d8b; }
.object-card.category-list { border-left: 3px solid #795548; }
.object-card.category-object { border-left: 3px solid #00bcd4; }
.object-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: linear-gradient(to right, rgba(255,255,255,0.03), transparent);
cursor: pointer;
user-select: none;
}
.object-header:hover { background: rgba(255,255,255,0.05); }
.object-icon { font-size: 18px; }
.object-type {
font-family: 'SF Mono', Consolas, monospace;
font-weight: 600;
color: ${theme.accent || '#4fc3f7'};
flex: 1;
}
.object-category {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: rgba(255,255,255,0.1);
color: ${theme.fg}88;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.object-toggle {
font-size: 10px;
color: ${theme.fg}66;
transition: transform 0.2s;
}
.object-content.collapsed + .object-header .object-toggle,
.object-content.collapsed ~ .object-toggle { transform: rotate(-90deg); }
.object-content {
border-top: 1px solid ${theme.border};
}
.object-content.collapsed { display: none; }
.object-section {
border-bottom: 1px solid ${theme.border}44;
}
.object-section:last-child { border-bottom: none; }
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
background: rgba(0,0,0,0.1);
cursor: pointer;
user-select: none;
}
.section-header:hover { background: rgba(0,0,0,0.15); }
.section-title {
font-size: 12px;
font-weight: 600;
color: ${theme.fg}cc;
}
.section-toggle {
font-size: 9px;
color: ${theme.fg}66;
transition: transform 0.2s;
}
.section-content.collapsed + .section-header .section-toggle { transform: rotate(-90deg); }
.section-content {
padding: 12px 14px;
}
.section-content.collapsed { display: none; }
/* Metrics Grid */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.metric-item {
display: flex;
flex-direction: column;
padding: 8px 10px;
background: rgba(255,255,255,0.03);
border-radius: 6px;
border: 1px solid ${theme.border}33;
}
.metric-item.highlight {
background: rgba(79, 195, 247, 0.08);
border-color: rgba(79, 195, 247, 0.3);
}
.metric-item.stat {
background: rgba(156, 39, 176, 0.08);
border-color: rgba(156, 39, 176, 0.2);
}
.metric-label {
font-size: 10px;
color: ${theme.fg}88;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 2px;
}
.metric-value {
font-family: 'SF Mono', Consolas, monospace;
font-size: 13px;
font-weight: 500;
color: ${theme.fg};
word-break: break-all;
}
.metric-value.dtype { color: #ff9800; }
.metric-value.device { color: #4caf50; }
.metric-divider {
grid-column: 1 / -1;
height: 1px;
background: ${theme.border}44;
margin: 4px 0;
}
/* Tensor Table */
.tensor-table-wrapper {
grid-column: 1 / -1;
max-height: 300px;
overflow-y: auto;
border-radius: 6px;
border: 1px solid ${theme.border}44;
}
.tensor-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
.tensor-table th {
position: sticky;
top: 0;
background: ${theme.bg};
padding: 6px 8px;
text-align: left;
font-weight: 600;
color: ${theme.fg}aa;
border-bottom: 1px solid ${theme.border};
}
.tensor-table td {
padding: 4px 8px;
border-bottom: 1px solid ${theme.border}22;
font-family: 'SF Mono', Consolas, monospace;
}
.tensor-table .tensor-name {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: ${theme.accent || '#4fc3f7'};
}
.tensor-table .truncated-row td { color: ${theme.fg}66; font-style: italic; }
.tensor-table .error-row .error { color: #f44336; }
/* Spectral/Histogram */
.spectral-container {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.channel-histogram {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.channel-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.histogram-canvas {
background: rgba(0,0,0,0.2);
border-radius: 4px;
border: 1px solid ${theme.border}44;
}
.histogram-canvas.wide {
width: 100%;
max-width: 400px;
}
.single-histogram {
display: flex;
justify-content: center;
}
/* Attributes Table */
.attributes-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.attributes-table th {
text-align: left;
padding: 6px 8px;
background: rgba(0,0,0,0.15);
color: ${theme.fg}aa;
font-weight: 600;
}
.attributes-table td {
padding: 4px 8px;
border-bottom: 1px solid ${theme.border}22;
vertical-align: top;
}
.attributes-table .attr-name {
font-family: 'SF Mono', Consolas, monospace;
color: ${theme.accent || '#4fc3f7'};
white-space: nowrap;
}
.attributes-table .attr-type {
font-family: 'SF Mono', Consolas, monospace;
color: #ff9800;
font-size: 11px;
}
.attributes-table .attr-value {
font-family: 'SF Mono', Consolas, monospace;
color: ${theme.fg}cc;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Source Info */
.source-info {
display: flex;
flex-direction: column;
gap: 6px;
}
.source-item {
display: flex;
gap: 8px;
}
.source-label {
font-size: 11px;
color: ${theme.fg}88;
min-width: 50px;
}
.source-value {
font-family: 'SF Mono', Consolas, monospace;
font-size: 12px;
color: ${theme.fg};
word-break: break-all;
}
.source-value.path { color: #4caf50; }
/* Serialized Content */
.serialized-content {
background: rgba(0,0,0,0.2);
padding: 12px;
border-radius: 6px;
font-family: 'SF Mono', Consolas, monospace;
font-size: 11px;
line-height: 1.5;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
max-height: 400px;
overflow-y: auto;
margin: 0;
color: ${theme.fg}dd;
}
/* Scrollbar */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: ${theme.fg}33; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: ${theme.fg}55; }
`;
}
// Scripts are included inline in render() to ensure they're available immediately
static getScripts() {
return "";
}
}
export default ObjectView;
+51
View File
@@ -0,0 +1,51 @@
/**
* Python View - Python code renderer with syntax highlighting
*/
import { BaseView, escapeHtml } from "./base_view.js";
class PythonView extends BaseView {
static id = "python";
static displayName = "Python";
static priority = 60;
static detect(content) {
const trimmed = content.trim();
let score = 0;
const pyPatterns = [
[/^import\s+\w+/m, 3],
[/^from\s+\w+\s+import/m, 3],
[/^def\s+\w+\s*\(/m, 3],
[/^class\s+\w+/m, 3],
[/self\.\w+/, 2],
[/__init__/, 2],
[/__name__/, 2],
[/:\s*$/m, 1],
[/^\s+return\s+/m, 1],
[/^\s+if\s+.+:/m, 1],
[/^\s+for\s+.+:/m, 1],
[/^\s+elif\s+/m, 2],
[/True|False|None/, 1],
];
for (const [pattern, weight] of pyPatterns) {
if (pattern.test(trimmed)) score += weight;
}
return score;
}
static render(content, theme) {
return `<pre><code class="language-python">${escapeHtml(content)}</code></pre>`;
}
static getStyles(theme) {
return "";
}
static needsPrism() {
return true;
}
}
export default PythonView;
+38
View File
@@ -0,0 +1,38 @@
/**
* SVG View - SVG content renderer
*/
import { BaseView } from "./base_view.js";
class SvgView extends BaseView {
static id = "svg";
static displayName = "SVG";
static priority = 110;
static detect(content) {
const trimmed = content.trim();
if (trimmed.startsWith("<svg") ||
(trimmed.startsWith("<?xml") && trimmed.includes("<svg")) ||
(trimmed.startsWith("<") && trimmed.includes("xmlns") && trimmed.includes("<svg"))) {
return 100;
}
return 0;
}
static render(content, theme) {
return `<div style="display:flex;justify-content:center;align-items:center;min-height:100%;padding:16px;">${content}</div>`;
}
static getStyles(theme) {
return `
svg {
max-width: 100%;
height: auto;
}
`;
}
}
export default SvgView;
+25
View File
@@ -0,0 +1,25 @@
/**
* Text View - Plain text content renderer
*/
import { BaseView, escapeHtml } from "./base_view.js";
class TextView extends BaseView {
static id = "text";
static displayName = "Text";
static priority = 0;
static detect(content) {
return 1;
}
static render(content, theme) {
return `<pre>${escapeHtml(content)}</pre>`;
}
static getStyles(theme) {
return "";
}
}
export default TextView;
+482
View File
@@ -0,0 +1,482 @@
/**
* View Loader for ComfyUI Viewer
* Dynamically loads and manages view extensions from the /views/ directory.
*
* Views are loaded from view_manifest.js which lists available view files.
* Extensions can register additional views by calling registerView() after load.
*/
import { VIEW_FILES } from "./view_manifest.js";
const VIEW_REGISTRY = new Map();
let viewsLoaded = false;
let loadPromise = null;
/**
* Get the base path for view modules
* @returns {string}
*/
function getViewsBasePath() {
const basePath = import.meta.url.substring(0, import.meta.url.lastIndexOf("/"));
return basePath;
}
/**
* Register a view module
* @param {object} viewModule - View module with static methods
*/
export function registerView(viewModule) {
if (!viewModule.id) {
console.error("[WAS Viewer] View module missing id:", viewModule);
return;
}
VIEW_REGISTRY.set(viewModule.id, viewModule);
console.log(`[WAS Viewer] Registered view: ${viewModule.id}`);
}
/**
* Get a registered view by id
* @param {string} id
* @returns {object|null}
*/
export function getView(id) {
return VIEW_REGISTRY.get(id) || null;
}
/**
* Get all registered views
* @returns {Map}
*/
export function getAllViews() {
return VIEW_REGISTRY;
}
/**
* Load all view modules from the views directory
* @returns {Promise<void>}
*/
export async function loadAllViews() {
if (viewsLoaded) return;
if (loadPromise) return loadPromise;
loadPromise = (async () => {
const basePath = getViewsBasePath();
for (const file of VIEW_FILES) {
try {
const module = await import(`${basePath}/${file}`);
if (module.default) {
registerView(module.default);
}
} catch (e) {
console.error(`[WAS Viewer] Failed to load view module: ${file}`, e);
}
}
viewsLoaded = true;
console.log(`[WAS Viewer] Loaded ${VIEW_REGISTRY.size} view modules`);
})();
return loadPromise;
}
/**
* Initialize all view scripts
* @param {string} basePath - Base path for scripts (web folder)
* @returns {Promise<void>}
*/
export async function initializeViewScripts(basePath) {
const promises = [];
for (const [id, view] of VIEW_REGISTRY) {
if (view.loadScripts) {
promises.push(
view.loadScripts(basePath).catch(e => {
console.error(`[WAS Viewer] Failed to load scripts for view: ${id}`, e);
})
);
}
}
await Promise.all(promises);
}
/**
* Detect the best view for content
* @param {string} content - Content to analyze
* @returns {string} - View id
*/
export function detectContentType(content) {
if (!content || typeof content !== "string") return "text";
const trimmed = content.trim();
let bestView = "text";
let bestScore = 0;
const sortedViews = Array.from(VIEW_REGISTRY.values())
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
for (const view of sortedViews) {
if (view.detect) {
const score = view.detect(trimmed);
if (score > bestScore) {
bestScore = score;
bestView = view.id;
}
}
}
return bestScore >= 2 ? bestView : "text";
}
/**
* Render content using the appropriate view
* @param {string} content - Content to render
* @param {string} contentType - Content type id
* @param {object} theme - Theme tokens
* @returns {string} - Rendered HTML
*/
export function renderContent(content, contentType, theme) {
const view = VIEW_REGISTRY.get(contentType);
if (view && view.render) {
return view.render(content, theme);
}
const textView = VIEW_REGISTRY.get("text");
if (textView && textView.render) {
return textView.render(content, theme);
}
return `<pre>${content}</pre>`;
}
/**
* Get styles for a view type
* @param {string} contentType
* @param {object} theme
* @returns {string}
*/
export function getViewStyles(contentType, theme) {
const view = VIEW_REGISTRY.get(contentType);
if (view && view.getStyles) {
return view.getStyles(theme);
}
return "";
}
/**
* Get scripts for a view type (returns HTML script tags - legacy)
* @param {string} contentType
* @param {string} content
* @returns {string}
*/
export function getViewScripts(contentType, content) {
const view = VIEW_REGISTRY.get(contentType);
if (view && view.getScripts) {
return view.getScripts(content);
}
return "";
}
/**
* Get raw script data for postMessage injection
* @param {string} contentType
* @param {string} content
* @returns {Array<{code: string, init?: string}>} Array of scripts to inject
*/
export function getViewScriptData(contentType, content) {
const view = VIEW_REGISTRY.get(contentType);
if (view && view.getScriptData) {
return view.getScriptData(content);
}
return [];
}
/**
* Check if a view is ready (scripts loaded)
* @param {string} contentType
* @returns {boolean}
*/
export function isViewReady(contentType) {
const view = VIEW_REGISTRY.get(contentType);
if (view && view.isReady) {
return view.isReady();
}
return true;
}
/**
* Check if a view uses base iframe styles
* @param {string} contentType
* @returns {boolean}
*/
export function viewUsesBaseStyles(contentType) {
const view = VIEW_REGISTRY.get(contentType);
if (view && typeof view.usesBaseStyles === 'function') {
return view.usesBaseStyles();
}
return true; // Default: use base styles
}
/**
* Get display name for a view type
* @param {string} contentType
* @returns {string}
*/
export function getViewDisplayName(contentType) {
const view = VIEW_REGISTRY.get(contentType);
return view?.displayName || "Unknown";
}
/**
* Refresh callback registry for views that load scripts asynchronously
*/
const refreshCallbacks = new Set();
export function onViewsRefresh(callback) {
refreshCallbacks.add(callback);
}
export function triggerViewsRefresh() {
for (const callback of refreshCallbacks) {
try {
callback();
} catch (e) {
console.error("[WAS Viewer] Refresh callback error:", e);
}
}
}
/**
* Find a view that handles a specific message type
* @param {string} messageType - The message type from postMessage
* @returns {object|null} - View module or null
*/
export function getViewForMessageType(messageType) {
for (const [id, view] of VIEW_REGISTRY) {
if (view.getMessageTypes && view.getMessageTypes().includes(messageType)) {
return view;
}
}
return null;
}
/**
* Get raw content from a node (checks manual_content widget first, then connected content)
* @param {object} node - The LiteGraph node
* @returns {string} - Raw content string
*/
function getNodeRawContent(node) {
// Check manual_content widget
const manualWidget = node.widgets?.find(w => w.name === "manual_content");
if (manualWidget?.value) {
return manualWidget.value;
}
return "";
}
/**
* Handle a message from an iframe by routing to the appropriate view
* Automatically extracts view-specific content from multiview payloads.
*
* @param {string} messageType - The message type
* @param {object} data - The message data
* @param {object} node - The LiteGraph node
* @param {object} app - The ComfyUI app instance
* @param {Window} iframeSource - The iframe's contentWindow that sent the message
* @returns {boolean} - True if message was handled
*/
export function handleViewMessage(messageType, data, node, app, iframeSource = null) {
const view = getViewForMessageType(messageType);
if (view && view.handleMessage) {
// Get raw content and extract view-specific content if multiview
const rawContent = getNodeRawContent(node);
const viewContent = extractViewContent(rawContent, view.id);
// Augment data with extracted content for the view
const augmentedData = {
...data,
_viewContent: viewContent, // View-specific content (multiview extracted)
_rawContent: rawContent, // Original raw content
};
return view.handleMessage(messageType, augmentedData, node, app, iframeSource);
}
return false;
}
/**
* Get view-specific state from node for a content type
* @param {string} contentType - The content type
* @param {object} node - The LiteGraph node
* @returns {object|null} - State object or null
*/
export function getViewState(contentType, node) {
const view = VIEW_REGISTRY.get(contentType);
if (view && view.getStateFromWidget) {
return view.getStateFromWidget(node);
}
return null;
}
/**
* Inject saved state into content for a content type
* @param {string} contentType - The content type
* @param {string} content - Original content
* @param {object} state - State to inject
* @returns {string} - Modified content
*/
export function injectViewState(contentType, content, state) {
if (!state) return content;
const view = VIEW_REGISTRY.get(contentType);
if (view && view.injectState) {
return view.injectState(content, state);
}
return content;
}
/**
* Strip content marker from content if present
* @param {string} content - Content that may have a marker prefix
* @returns {{ content: string, view: object|null }} - Stripped content and matched view
*/
export function stripContentMarker(content) {
if (!content || typeof content !== 'string') {
return { content: content || '', view: null };
}
for (const [id, view] of VIEW_REGISTRY) {
if (view.getContentMarker) {
const marker = view.getContentMarker();
if (marker && content.startsWith(marker)) {
return {
content: content.slice(marker.length),
view: view
};
}
}
}
return { content, view: null };
}
/**
* Multi-view marker for content that can be displayed by multiple views
*/
export const MULTIVIEW_MARKER = "$WAS_MULTIVIEW$";
/**
* Check if content is a multi-view payload
* @param {string} content - Content to check
* @returns {boolean}
*/
export function isMultiviewContent(content) {
return content && typeof content === 'string' && content.startsWith(MULTIVIEW_MARKER);
}
/**
* Parse multi-view payload and return view data
* @param {string} content - Multi-view content with marker
* @returns {{ defaultView: string, views: Array<{name: string, displayContent: string}> }|null}
*/
export function parseMultiviewContent(content) {
if (!isMultiviewContent(content)) return null;
try {
const jsonStr = content.slice(MULTIVIEW_MARKER.length);
const data = JSON.parse(jsonStr);
if (data.type !== 'multiview' || !Array.isArray(data.views)) {
return null;
}
return {
defaultView: data.default_view,
views: data.views.map(v => ({
name: v.name,
priority: v.priority,
displayContent: v.display_content,
contentHash: v.content_hash,
})),
};
} catch (e) {
console.error('[WAS Viewer] Failed to parse multiview content:', e);
return null;
}
}
/**
* Get display content for a specific view from multi-view payload
* @param {string} content - Multi-view content
* @param {string} viewName - Name of view to get content for
* @returns {string|null} - View-specific display content or null
*/
export function getMultiviewContent(content, viewName) {
const parsed = parseMultiviewContent(content);
if (!parsed) return null;
const view = parsed.views.find(v => v.name === viewName);
return view ? view.displayContent : null;
}
/**
* Get list of available views from multi-view payload
* @param {string} content - Multi-view content
* @returns {Array<{name: string, displayName: string}>}
*/
export function getMultiviewOptions(content) {
const parsed = parseMultiviewContent(content);
if (!parsed) return [];
return parsed.views.map(v => ({
name: v.name,
displayName: getViewDisplayName(v.name),
priority: v.priority,
}));
}
/**
* Extract view-specific content from raw content (handles multiview automatically)
* This is the centralized function that view extensions should NOT need to call -
* the viewer system uses this to provide clean content to views.
*
* @param {string} rawContent - Raw content that may be multiview or single-view
* @param {string} viewName - Name of the view to extract content for
* @returns {string} - View-specific content, or original content if not multiview
*/
export function extractViewContent(rawContent, viewName) {
if (!rawContent || typeof rawContent !== 'string') {
return rawContent || '';
}
// Check if it's multiview content
if (!isMultiviewContent(rawContent)) {
return rawContent;
}
// Extract view-specific content from multiview payload
const viewContent = getMultiviewContent(rawContent, viewName);
if (viewContent) {
return viewContent;
}
// Fallback: return first available view's content
const parsed = parseMultiviewContent(rawContent);
if (parsed && parsed.views.length > 0) {
return parsed.views[0].displayContent;
}
return rawContent;
}
/**
* Parse content with marker and return parsed data
* @param {string} content - Content that may have a marker prefix
* @returns {object|null} - Parsed JSON data or null
*/
export function parseMarkedContent(content) {
const { content: stripped, view } = stripContentMarker(content);
if (!stripped) return null;
try {
return JSON.parse(stripped);
} catch {
return null;
}
}
+27
View File
@@ -0,0 +1,27 @@
/**
* View Manifest for ComfyUI Viewer
*
* This file lists all view modules to be loaded.
* Extensions can add their own views by importing and calling registerView()
* after views are loaded, or by creating their own manifest.
*
* To add a new view:
* 1. Create a view file (e.g., myview.js) that exports a class extending BaseView
* 2. Add the filename to VIEW_FILES array below
*/
export const VIEW_FILES = [
"canvas.js",
"html.js",
"svg.js",
"markdown.js",
"json.js",
"csv.js",
"yaml.js",
"ansi.js",
"python.js",
"javascript.js",
"css.js",
"object.js",
"text.js",
];
+89
View File
@@ -0,0 +1,89 @@
/**
* YAML View - YAML code renderer with custom syntax highlighting
*/
import { BaseView, escapeHtml, highlightSyntax } from "./base_view.js";
class YamlView extends BaseView {
static id = "yaml";
static displayName = "YAML";
static priority = 40;
static highlightRules = [
// Comments (highest priority)
{ name: "comment", pattern: /#.*$/m, className: "yaml-comment" },
// Document separators
{ name: "separator", pattern: /^---\s*$/m, className: "yaml-separator" },
{ name: "separator-end", pattern: /^\.\.\.\s*$/m, className: "yaml-separator" },
// List item markers
{ name: "list-marker", pattern: /^(\s*)-(?=\s|$)/m, className: "yaml-list-marker" },
// Keys (word before colon)
{ name: "key", pattern: /^(\s*)([^\s#:][^:#]*?)(?=:\s|:$)/m, className: "yaml-key" },
// Quoted strings
{ name: "string-double", pattern: /"(?:[^"\\]|\\.)*"/g, className: "yaml-string" },
{ name: "string-single", pattern: /'(?:[^'\\]|\\.)*'/g, className: "yaml-string" },
// Booleans
{ name: "boolean", pattern: /\b(true|false|yes|no|on|off)\b/gi, className: "yaml-boolean" },
// Null
{ name: "null", pattern: /\b(null|~)\b/gi, className: "yaml-null" },
// Numbers
{ name: "number", pattern: /\b-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\b/g, className: "yaml-number" },
// Anchors and aliases
{ name: "anchor", pattern: /&[\w-]+/g, className: "yaml-anchor" },
{ name: "alias", pattern: /\*[\w-]+/g, className: "yaml-alias" },
// Tags
{ name: "tag", pattern: /![\w!\/.-]*/g, className: "yaml-tag" },
];
static detect(content) {
const trimmed = content.trim();
let score = 0;
const yamlPatterns = [
[/^[\w-]+:\s*.+$/m, 2],
[/^[\w-]+:\s*$/m, 2],
[/^\s+-\s+.+$/m, 1],
[/^---\s*$/m, 3],
];
for (const [pattern, weight] of yamlPatterns) {
if (pattern.test(trimmed)) score += weight;
}
if (trimmed.includes(": ") && !trimmed.includes("{") && !trimmed.includes("<")) {
score += 1;
}
return score;
}
static render(content, theme) {
const highlighted = highlightSyntax(content, this.highlightRules);
return `<pre class="yaml-code"><code>${highlighted}</code></pre>`;
}
static getStyles(theme) {
return `
.yaml-code {
margin: 0;
font-size: 13px;
line-height: 1.5;
}
.yaml-comment { color: #6a9955; font-style: italic; }
.yaml-separator { color: #569cd6; }
.yaml-list-marker { color: #c586c0; font-weight: bold; }
.yaml-key { color: #9cdcfe; }
.yaml-string { color: #ce9178; }
.yaml-boolean { color: #569cd6; }
.yaml-null { color: #569cd6; font-style: italic; }
.yaml-number { color: #b5cea8; }
.yaml-anchor { color: #dcdcaa; }
.yaml-alias { color: #dcdcaa; font-style: italic; }
.yaml-tag { color: #4ec9b0; }
`;
}
static needsPrism() {
return false;
}
}
export default YamlView;