diff --git a/.gitignore b/.gitignore index c74ef74..e527dfb 100644 --- a/.gitignore +++ b/.gitignore @@ -155,3 +155,9 @@ cython_debug/ # Ignore Windows ADS quarantine metadata files *:Zone.Identifier *:zone.identifier + +# Ruff +.ruff_cache + +# WAS Architect +.was_arch \ No newline at end of file diff --git a/LICENSE b/LICENSE index 266518a..513587b 100644 --- a/LICENSE +++ b/LICENSE @@ -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 diff --git a/README.md b/README.md index 7ab3182..d1d1a9e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ![Content Viewer](https://img.shields.io/badge/ComfyUI-Custom_Node-blue) ![License](https://img.shields.io/badge/License-MIT-green) -A versatile ComfyUI custom node that renders HTML, SVG, Markdown, and code content in a secure embedded iframe viewer with full editing, download, and list support. +A powerful, extensible ComfyUI custom node with a modular **Views Extension System** that renders content in a secure embedded iframe viewer. Views are dynamically loaded and can be added, removed, or modified without touching core code.
Screenshots @@ -13,72 +13,157 @@ A versatile ComfyUI custom node that renders HTML, SVG, Markdown, and code conte ![Screenshot 3](screenshots/screenshot_3.jpg) ![Screenshot 4](screenshots/screenshot_4.jpg) ![Screenshot 5](screenshots/screenshot_5.jpg) -![Screenshot 5](screenshots/screenshot_6.jpg) -![Screenshot 4](screenshots/screenshot_7.jpg) -![Screenshot 5](screenshots/screenshot_8.jpg) -![Screenshot 5](screenshots/screenshot_9.jpg) +![Screenshot 6](screenshots/screenshot_6.jpg) +![Screenshot 7](screenshots/screenshot_7.jpg) +![Screenshot 8](screenshots/screenshot_8.jpg) +![Screenshot 9](screenshots/screenshot_9.jpg) +![Screenshot 6](screenshots/screenshot_10.jpg) +![Screenshot 7](screenshots/screenshot_11.jpg) +![Screenshot 8](screenshots/screenshot_12.jpg) +![Screenshot 9](screenshots/screenshot_13.jpg) +![Screenshot 9](screenshots/screenshot_14.jpg)
## 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 | ` +Canvas View - Infinite canvas image compositor + +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. + + + +
+Markdown View - Rich text with diagrams and math + +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 `- [ ]` + +
+ +
+JSON View - Interactive tree viewer + +- **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 + +
+ +
+Terminal View - ANSI escape code parser + +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 + +
+ +--- ## 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** | ` 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 `
+

My Custom View

+
${escapeHtml(content)}
+
`; + } + + // 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 ` +
+
+
+ + + +
+
+
+ + + +
+
+
+ + +
+
+
+ + + × + + +
+
+
+ + + +
+
+ +
+
+ +
+
+
+
+
+
+ + + 100% +
+
+ + +
+
+
+
+ Layers +
+ + + +
+
+
+
+
+
+ Source Images + +
+
+
+
+
Navigator
+
+ +
+
+
+
+
+ +
+ `; + } + + static getStyles(theme) { + // Use CSS variables from ComfyUI theme for consistent styling + return ` + *, *::before, *::after { + box-sizing: border-box; + } + html, body { + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; + } + #canvas-composer { + display: flex; + flex-direction: column; + height: 100%; + background: var(--theme-bg); + color: var(--theme-fg); + font-family: system-ui, sans-serif; + font-size: 12px; + } + .canvas-toolbar { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + background: var(--theme-panel-header, var(--theme-bg-dark)); + border-bottom: 1px solid var(--theme-border); + flex-wrap: wrap; + min-height: 42px; + } + .brush-toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 12px; + background: var(--theme-bg); + border-bottom: 1px solid var(--theme-border); + min-height: 36px; + } + .brush-toolbar .toolbar-group { + display: flex; + align-items: center; + gap: 6px; + } + .brush-toolbar label { font-size: 11px; color: var(--theme-fg-muted); white-space: nowrap; } + .brush-toolbar input[type="range"] { width: 60px; accent-color: var(--theme-accent); } + .brush-toolbar select { + background: var(--theme-input-bg); + color: var(--theme-fg); + border: 1px solid var(--theme-input-border); + border-radius: 4px; + padding: 3px 6px; + font-size: 11px; + } + .brush-toolbar select:focus { border-color: var(--theme-input-focus); outline: none; } + .brush-toolbar span { font-size: 10px; color: var(--theme-fg-muted); min-width: 24px; } + .brush-toolbar .checkbox-label { cursor: pointer; font-size: 11px; } + .brush-toolbar .checkbox-label input[type="checkbox"] { accent-color: var(--theme-accent); } + .toolbar-group { + display: flex; + align-items: center; + gap: 6px; + } + .toolbar-separator { + width: 1px; + height: 24px; + background: var(--theme-border); + margin: 0 4px; + } + .toolbar-spacer { + flex: 1; + } + .tool-btn { + width: 32px; + height: 32px; + border: 1px solid var(--theme-input-border); + background: var(--theme-input-bg); + color: var(--theme-fg); + border-radius: 6px; + cursor: pointer; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; + } + .tool-btn:hover { background: var(--theme-accent-bg); border-color: var(--theme-accent); } + .tool-btn.active { background: var(--theme-accent); color: #fff; border-color: var(--theme-accent); box-shadow: 0 2px 4px var(--theme-shadow); } + .canvas-toolbar input[type="range"] { width: 60px; accent-color: var(--theme-accent); } + .canvas-toolbar input[type="number"] { width: 50px; background: var(--theme-input-bg); color: var(--theme-fg); border: 1px solid var(--theme-input-border); border-radius: 4px; padding: 4px; text-align: center; } + .canvas-toolbar input[type="number"]::-webkit-inner-spin-button { opacity: 0; } + .canvas-toolbar input[type="number"]:focus { border-color: var(--theme-input-focus); outline: none; } + .export-size-group span { color: var(--theme-fg-muted); font-size: 11px; } + .canvas-toolbar select, .layer-control-row select { + background: var(--theme-input-bg); + color: var(--theme-fg); + border: 1px solid var(--theme-input-border); + border-radius: 4px; + padding: 4px 6px; + transform-origin: top left; + } + .canvas-toolbar select:focus, .layer-control-row select:focus { border-color: var(--theme-input-focus); outline: none; } + .canvas-toolbar label { display: flex; align-items: center; gap: 4px; white-space: nowrap; font-size: 11px; color: var(--theme-fg-muted); } + .checkbox-label { cursor: pointer; } + .checkbox-label input[type="checkbox"] { accent-color: var(--theme-accent); } + .action-btn { + padding: 6px 12px; + background: var(--theme-input-bg); + color: var(--theme-fg); + border: 1px solid var(--theme-input-border); + border-radius: 6px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + display: flex; + align-items: center; + gap: 4px; + transition: all 0.15s ease; + } + .action-btn:hover { background: var(--theme-accent-bg); border-color: var(--theme-accent); } + .action-btn.primary { background: var(--theme-accent); color: #fff; border-color: var(--theme-accent); } + .action-btn.primary:hover { background: var(--theme-accent-hover); box-shadow: 0 2px 6px var(--theme-shadow); } + .action-btn.icon-only { padding: 6px 8px; min-width: 32px; justify-content: center; } + .canvas-workspace { + display: flex; + flex: 1; + overflow: hidden; + position: relative; + } + .canvas-container { + flex: 1; + position: relative; + overflow: hidden; + background: repeating-conic-gradient(#333 0% 25%, #444 0% 50%) 50% / 20px 20px; + min-width: 0; + min-height: 0; + } + #main-canvas { + display: block; + } + .layers-drawer { + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 200px; + background: var(--theme-panel-bg, var(--theme-bg)); + border-left: 1px solid var(--theme-border); + display: flex; + transition: transform 0.2s ease; + z-index: 100; + } + .layers-drawer.collapsed { + transform: translateX(200px); + } + .drawer-toggle { + position: absolute; + left: -24px; + top: 50%; + transform: translateY(-50%); + width: 24px; + height: 48px; + background: var(--theme-panel-bg, var(--theme-bg)); + border: 1px solid var(--theme-border); + border-right: none; + border-radius: 6px 0 0 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: var(--theme-fg); + } + .drawer-toggle:hover { background: var(--theme-accent-bg); } + .layers-drawer.collapsed .drawer-toggle { + transform: translateY(-50%) translateX(-200px); + } + .layers-drawer.collapsed .drawer-toggle::after { content: '▶'; } + .drawer-toggle::after { content: '◀'; } + .drawer-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + } + .drawer-section { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + border-bottom: 1px solid var(--theme-border); + } + .drawer-section:last-child { border-bottom: none; } + .drawer-section.layer-controls-section { + flex: 0 0 auto; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 6px; + } + .layer-control-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + position: relative; + } + .layer-control-row label { + color: var(--theme-fg-muted); + white-space: nowrap; + min-width: 50px; + } + .layer-control-row select { + flex: 1; + padding: 4px 6px; + font-size: 11px; + background: var(--theme-input-bg); + color: var(--theme-fg); + border: 1px solid var(--theme-border); + border-radius: 3px; + min-width: 0; + } + .layer-control-row input[type="range"] { + flex: 1; + min-width: 0; + accent-color: var(--theme-accent); + } + .layer-control-row span { + font-size: 10px; + min-width: 32px; + text-align: right; + } + .drawer-header { + padding: 8px 10px; + font-weight: bold; + font-size: 11px; + text-transform: uppercase; + border-bottom: 1px solid var(--theme-border); + background: var(--theme-panel-header, var(--theme-bg-dark)); + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + } + .drawer-header-buttons { + display: flex; + gap: 4px; + } + .drawer-header button { + font-size: 10px; + padding: 2px 6px; + background: var(--theme-accent); + color: #fff; + border: none; + border-radius: 3px; + cursor: pointer; + } + .drawer-header button:hover { opacity: 0.8; } + .layers-list, .images-list { + flex: 1; + overflow-y: auto; + padding: 6px; + } + .layers-list::-webkit-scrollbar, .images-list::-webkit-scrollbar { + width: 6px; + } + .layers-list::-webkit-scrollbar-thumb, .images-list::-webkit-scrollbar-thumb { + background: var(--theme-scrollbar-thumb); + border-radius: 3px; + } + .layers-list::-webkit-scrollbar-track, .images-list::-webkit-scrollbar-track { + background: var(--theme-scrollbar-track); + } + .mini-map-section { + flex: 0 !important; + min-height: auto !important; + } + .mini-map-container { + padding: 6px; + background: #000; + } + #mini-map-canvas { + width: 100%; + height: 80px; + display: block; + cursor: crosshair; + } + .layer-item, .image-item { + padding: 6px 8px; + margin: 2px 0; + background: var(--theme-bg-light); + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + user-select: none; + } + .layer-item:hover, .image-item:hover { background: var(--theme-accent-bg); } + .layer-item.selected { background: var(--theme-selection); outline: 1px solid var(--theme-accent); } + .layer-item.drag-over { border-top: 2px solid var(--theme-accent); } + .layer-item.dragging { opacity: 0.5; } + .layer-thumb { + width: 28px; + height: 28px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + background-color: var(--theme-bg); + border: 1px solid var(--theme-border); + border-radius: 3px; + flex-shrink: 0; + } + .layer-thumb.draw-layer { + background: linear-gradient(45deg, #666 25%, transparent 25%), linear-gradient(-45deg, #666 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #666 75%), linear-gradient(-45deg, transparent 75%, #666 75%); + background-size: 8px 8px; + background-position: 0 0, 0 4px, 4px -4px, -4px 0px; + } + .layer-info { flex: 1; overflow: hidden; min-width: 0; } + .layer-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 11px; } + .layer-type { font-size: 9px; color: var(--theme-fg-muted); } + .layer-controls { + display: flex; + gap: 4px; + flex-shrink: 0; + } + .layer-btn { + width: 20px; + height: 20px; + border: none; + background: transparent; + color: var(--theme-fg); + cursor: pointer; + border-radius: 3px; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + } + .layer-btn:hover { background: var(--theme-accent-bg); } + .layer-btn.visibility-off { opacity: 0.4; } + .layer-btn.effects-btn { color: var(--theme-fg-muted); } + .layer-btn.effects-btn:hover { color: var(--theme-accent); } + .layer-btn.effects-btn.has-effects { color: var(--theme-accent); background: var(--theme-accent-bg); } + .layer-btn.mask-add-btn { color: var(--theme-fg-disabled); font-size: 10px; } + .layer-btn.mask-add-btn:hover { color: var(--theme-warning); } + .layer-btn.mask-edit-btn { color: var(--theme-fg-muted); } + .layer-btn.mask-edit-btn:hover { color: var(--theme-warning); } + .layer-btn.mask-edit-btn.editing-mask { color: #ff6600; background: #ff660033; } + .layer-item.has-mask { border-left: 2px solid var(--theme-warning); } + .image-item { + cursor: grab; + } + .image-item:active { cursor: grabbing; } + + /* Modal styles */ + .modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(2px); + } + .modal-content { + background: var(--theme-panel-bg, var(--theme-bg)); + border: 1px solid var(--theme-border); + border-radius: 12px; + width: 360px; + max-width: 90vw; + max-height: 80vh; + overflow: hidden; + box-shadow: 0 8px 32px var(--theme-shadow); + } + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--theme-border); + background: var(--theme-panel-header, var(--theme-bg-dark)); + font-weight: 600; + } + .modal-close-btn { + width: 28px; + height: 28px; + border: none; + background: transparent; + color: var(--theme-fg); + font-size: 20px; + cursor: pointer; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + } + .modal-close-btn:hover { background: var(--theme-accent-bg); } + .modal-body { + padding: 12px 16px; + overflow-y: auto; + max-height: calc(80vh - 60px); + } + .effect-section { + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px solid var(--theme-border-light); + } + .effect-section:last-child { border-bottom: none; margin-bottom: 0; } + .effect-header { + font-weight: 600; + font-size: 12px; + margin-bottom: 10px; + color: var(--theme-fg); + } + .effect-controls { + padding-left: 8px; + } + .effect-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + font-size: 11px; + } + .effect-row:last-child { margin-bottom: 0; } + .effect-row label { min-width: 60px; color: var(--theme-fg-muted); } + .effect-row input[type="range"] { flex: 1; accent-color: var(--theme-accent); } + .effect-row input[type="color"] { width: 32px; height: 24px; border: 1px solid var(--theme-input-border); border-radius: 4px; cursor: pointer; } + .effect-row span { min-width: 30px; text-align: right; font-family: monospace; } + + /* Transform handles cursor styles */ + .canvas-container.cursor-nwse { cursor: nwse-resize; } + .canvas-container.cursor-nesw { cursor: nesw-resize; } + .canvas-container.cursor-rotate { cursor: grab; } + .canvas-container.cursor-move { cursor: move; } + `; + } + + static getScripts() { + return ` +