Refact for 0.2.0; Modular View Extension System; Canvas View; Object View; Numerous optimizations
This commit is contained in:
@@ -155,3 +155,9 @@ cython_debug/
|
||||
# Ignore Windows ADS quarantine metadata files
|
||||
*:Zone.Identifier
|
||||
*:zone.identifier
|
||||
|
||||
# Ruff
|
||||
.ruff_cache
|
||||
|
||||
# WAS Architect
|
||||
.was_arch
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||

|
||||

|
||||
|
||||
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
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</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
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
"""Parser modules for WAS Content Viewer."""
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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 |
+500
-1536
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 `;
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user