feat: Add interactive workflow canvas preview

- Add n8n-demo web component for visual workflow preview
- Split modal layout with canvas always visible on right side
- Add JSON viewer with copy button
- Support drag to pan and scroll to zoom
- Add theme support (light/dark)
- Mobile responsive design
This commit is contained in:
ami3466
2026-01-07 02:27:22 -05:00
parent 0be6f07693
commit 5f6e568f0f
+325 -165
View File
@@ -6,8 +6,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>⚡ N8N Workflow Documentation</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.6.1/dist/mermaid.min.js"></script>
<!-- n8n Demo Web Component for interactive workflow canvas preview -->
<script type="module" src="https://n8n-io.github.io/n8n-demo-webcomponent/n8n-demo.js"></script>
<!-- Web Components polyfill + n8n Demo Web Component for interactive workflow canvas preview -->
<script src="https://cdn.jsdelivr.net/npm/@webcomponents/webcomponentsjs@2.8.0/webcomponents-loader.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/@n8n_io/n8n-demo-component@latest/n8n-demo.bundled.js"></script>
<style>
/* Modern CSS Reset and Base */
* {
@@ -434,19 +435,22 @@
.modal-content {
background: var(--bg);
border-radius: 0.75rem;
max-width: 800px;
max-width: 1200px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 1.5rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.modal-title {
@@ -464,7 +468,189 @@
}
.modal-body {
display: flex;
flex: 1;
overflow: hidden;
}
.modal-left {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
min-width: 300px;
}
.modal-right {
width: 50%;
min-width: 400px;
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
background: var(--bg-secondary);
}
.modal-right-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-right-header h4 {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
}
.modal-right-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
display: flex;
gap: 0.75rem;
flex-shrink: 0;
}
.modal-right-footer .action-btn {
flex: 1;
text-align: center;
padding: 0.75rem 1rem;
font-size: 0.875rem;
}
.action-btn.ai-btn {
background: linear-gradient(135deg, #8b5cf6, #6366f1);
color: white;
border-color: transparent;
}
.action-btn.ai-btn:hover {
background: linear-gradient(135deg, #7c3aed, #4f46e5);
}
.action-btn.secondary {
background: var(--bg-tertiary);
color: var(--text);
border-color: var(--border);
}
.action-btn.secondary:hover {
background: var(--bg-secondary);
border-color: var(--primary);
}
.action-btn.secondary.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
/* JSON Panel */
.json-panel {
border-top: 1px solid var(--border);
max-height: 300px;
overflow-y: auto;
flex-shrink: 0;
}
.json-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
background: var(--bg-tertiary);
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary);
position: sticky;
top: 0;
}
.copy-btn-small {
background: var(--primary);
color: white;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.7rem;
cursor: pointer;
}
.copy-btn-small:hover {
background: var(--primary-dark);
}
.copy-btn-small.copied {
background: var(--success);
}
.json-panel .json-viewer {
margin: 0;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
font-family: 'Courier New', monospace;
font-size: 0.7rem;
color: var(--text);
white-space: pre-wrap;
word-break: break-all;
line-height: 1.4;
border: none;
border-radius: 0;
max-height: none;
}
.canvas-wrapper {
flex: 1;
position: relative;
overflow: hidden;
min-height: 300px;
}
.canvas-wrapper n8n-demo {
width: 100%;
height: 100%;
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.canvas-wrapper .canvas-loading,
.canvas-wrapper .canvas-error {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 0.875rem;
}
.canvas-wrapper .canvas-error {
color: var(--error);
text-align: center;
padding: 2rem;
}
@media (max-width: 900px) {
.modal-body {
flex-direction: column;
}
.modal-right {
width: 100%;
min-width: auto;
border-left: none;
border-top: 1px solid var(--border);
height: 400px;
}
}
.workflow-detail {
@@ -807,67 +993,45 @@
<button class="modal-close" id="modalClose">&times;</button>
</div>
<div class="modal-body">
<div class="workflow-detail">
<h4>Description</h4>
<p id="modalDescription">Loading...</p>
</div>
<div class="workflow-detail">
<h4>Statistics</h4>
<div id="modalStats">Loading...</div>
</div>
<div class="workflow-detail">
<h4>Integrations</h4>
<div id="modalIntegrations">Loading...</div>
</div>
<div class="workflow-detail">
<h4>Actions</h4>
<div class="workflow-actions">
<a id="downloadBtn" class="action-btn primary" href="#" download>📥 Download JSON</a>
<button id="viewCanvasBtn" class="action-btn">🎨 View Canvas</button>
<button id="viewJsonBtn" class="action-btn">📄 View JSON</button>
<button id="viewDiagramBtn" class="action-btn">📊 View Diagram</button>
<!-- Left side: Info -->
<div class="modal-left">
<div class="workflow-detail">
<h4>Description</h4>
<p id="modalDescription">Loading...</p>
</div>
<div class="workflow-detail">
<h4>Statistics</h4>
<div id="modalStats">Loading...</div>
</div>
<div class="workflow-detail">
<h4>Integrations</h4>
<div id="modalIntegrations">Loading...</div>
</div>
</div>
<div class="workflow-detail hidden" id="canvasSection">
<div class="section-header">
<h4>n8n Canvas Preview</h4>
<div class="diagram-controls">
<span class="zoom-info">Interactive canvas - drag to pan, scroll to zoom</span>
</div>
<!-- Right side: Canvas Preview (always visible) -->
<div class="modal-right">
<div class="modal-right-header">
<h4>Workflow Preview</h4>
<span class="zoom-info">Drag to pan • Scroll to zoom</span>
</div>
<div id="canvasContainer" class="canvas-container">
<div class="canvas-wrapper">
<div id="canvasViewer" class="canvas-loading">Loading canvas preview...</div>
</div>
</div>
<div class="workflow-detail hidden" id="jsonSection">
<div class="section-header">
<h4>Workflow JSON</h4>
<button id="copyJsonBtn" class="copy-btn" title="Copy JSON to clipboard">
📋 Copy
</button>
<div class="modal-right-footer">
<a id="downloadBtn" class="action-btn primary" href="#" download>📥 Download</a>
<button id="viewJsonBtn" class="action-btn secondary">📄 JSON</button>
<button id="editWithAIBtn" class="action-btn ai-btn">✨ Edit with AI</button>
</div>
<div class="json-viewer" id="jsonViewer">Loading...</div>
</div>
<div class="workflow-detail hidden" id="diagramSection">
<div class="section-header">
<h4>Workflow Diagram</h4>
<div class="diagram-controls">
<button id="zoomInBtn" class="zoom-btn" title="Zoom In">🔍+</button>
<button id="zoomOutBtn" class="zoom-btn" title="Zoom Out">🔍-</button>
<button id="zoomResetBtn" class="zoom-btn" title="Reset Zoom">🔄</button>
<button id="copyDiagramBtn" class="copy-btn" title="Copy diagram code to clipboard">
📋 Copy
</button>
<div class="json-panel hidden" id="jsonSection">
<div class="json-panel-header">
<span>Workflow JSON</span>
<button id="copyJsonBtn" class="copy-btn-small" title="Copy JSON">📋 Copy</button>
</div>
</div>
<div id="diagramContainer" class="diagram-container">
<div id="diagramViewer">Loading diagram...</div>
<pre class="json-viewer" id="jsonViewer">Loading...</pre>
</div>
</div>
</div>
@@ -925,22 +1089,12 @@
modalStats: document.getElementById('modalStats'),
modalIntegrations: document.getElementById('modalIntegrations'),
downloadBtn: document.getElementById('downloadBtn'),
viewCanvasBtn: document.getElementById('viewCanvasBtn'),
editWithAIBtn: document.getElementById('editWithAIBtn'),
viewJsonBtn: document.getElementById('viewJsonBtn'),
viewDiagramBtn: document.getElementById('viewDiagramBtn'),
canvasSection: document.getElementById('canvasSection'),
canvasContainer: document.getElementById('canvasContainer'),
canvasViewer: document.getElementById('canvasViewer'),
jsonSection: document.getElementById('jsonSection'),
jsonViewer: document.getElementById('jsonViewer'),
diagramSection: document.getElementById('diagramSection'),
diagramViewer: document.getElementById('diagramViewer'),
diagramContainer: document.getElementById('diagramContainer'),
copyJsonBtn: document.getElementById('copyJsonBtn'),
copyDiagramBtn: document.getElementById('copyDiagramBtn'),
zoomInBtn: document.getElementById('zoomInBtn'),
zoomOutBtn: document.getElementById('zoomOutBtn'),
zoomResetBtn: document.getElementById('zoomResetBtn')
copyJsonBtn: document.getElementById('copyJsonBtn')
};
this.searchDebounceTimer = null;
@@ -1041,38 +1195,18 @@
}
});
this.elements.viewCanvasBtn.addEventListener('click', () => {
this.toggleCanvasView();
});
this.elements.viewJsonBtn.addEventListener('click', () => {
this.toggleJsonView();
});
this.elements.viewDiagramBtn.addEventListener('click', () => {
this.toggleDiagramView();
});
// Copy button events
this.elements.copyJsonBtn.addEventListener('click', () => {
this.copyToClipboard(this.currentJsonData, 'copyJsonBtn');
});
this.elements.copyDiagramBtn.addEventListener('click', () => {
this.copyToClipboard(this.currentDiagramData, 'copyDiagramBtn');
});
// Zoom control events
this.elements.zoomInBtn.addEventListener('click', () => {
this.zoomDiagram(1.2);
});
this.elements.zoomOutBtn.addEventListener('click', () => {
this.zoomDiagram(0.8);
});
this.elements.zoomResetBtn.addEventListener('click', () => {
this.resetDiagramZoom();
// Edit with AI button
this.elements.editWithAIBtn.addEventListener('click', () => {
this.openEditWithAI();
});
// Keyboard shortcuts
@@ -1080,20 +1214,6 @@
if (e.key === 'Escape') {
this.closeModal();
}
// Zoom shortcuts when diagram is visible
if (!this.elements.diagramSection.classList.contains('hidden')) {
if (e.key === '+' || e.key === '=') {
e.preventDefault();
this.zoomDiagram(1.2);
} else if (e.key === '-') {
e.preventDefault();
this.zoomDiagram(0.8);
} else if (e.key === '0' && e.ctrlKey) {
e.preventDefault();
this.resetDiagramZoom();
}
}
});
}
@@ -1479,103 +1599,143 @@
this.elements.downloadBtn.href = `/api/workflows/${workflow.filename}/download`;
this.elements.downloadBtn.download = workflow.filename;
// Set Edit with AI link (FlowEngine import)
const workflowUrl = encodeURIComponent(window.location.origin + `/api/workflows/${workflow.filename}/download`);
this.elements.editWithAIBtn.href = `https://flowengine.cloud/import?workflow=${workflowUrl}&name=${encodeURIComponent(workflow.name)}`;
// Reset view states
this.elements.canvasSection.classList.add('hidden');
this.elements.jsonSection.classList.add('hidden');
this.elements.diagramSection.classList.add('hidden');
this.elements.workflowModal.classList.remove('hidden');
// Auto-load canvas preview
this.loadCanvasPreview();
}
closeModal() {
this.elements.workflowModal.classList.add('hidden');
this.currentWorkflow = null;
this.currentJsonData = null;
this.currentDiagramData = null;
this.diagramSvg = null;
this.diagramZoom = 1;
this.diagramPan = { x: 0, y: 0 };
this.isDragging = false;
// Reset button states
this.elements.viewCanvasBtn.textContent = '🎨 View Canvas';
this.elements.viewJsonBtn.textContent = '📄 View JSON';
this.elements.viewDiagramBtn.textContent = '📊 View Diagram';
this.elements.viewJsonBtn.textContent = '📄 JSON';
this.elements.viewJsonBtn.classList.remove('active');
// Reset copy button states
this.resetCopyButton('copyJsonBtn');
this.resetCopyButton('copyDiagramBtn');
// Clear canvas content
this.elements.canvasViewer.innerHTML = '<div class="canvas-loading">Loading canvas preview...</div>';
}
async toggleCanvasView() {
// FlowEngine node type mapping for proper icons in canvas preview
// Maps FlowEngine custom nodes to official n8n nodes for icon display
static PREVIEW_NODE_TYPE_MAP = {
'CUSTOM.flowEngineLlm': '@n8n/n8n-nodes-langchain.lmChatOpenAi',
'n8n-nodes-flowengine.flowEngine': 'n8n-nodes-base.code',
'n8n-nodes-flowengine-session-id.flowEngineSessionId': 'n8n-nodes-base.set',
'n8n-nodes-flowengine-data-standardize-clean.dataCleaner': 'n8n-nodes-base.itemLists',
};
// Map FlowEngine nodes to official n8n nodes for proper icon rendering
mapNodeTypesForPreview(workflowJson) {
if (!workflowJson || !workflowJson.nodes) return workflowJson;
const mappedWorkflow = JSON.parse(JSON.stringify(workflowJson));
mappedWorkflow.nodes = mappedWorkflow.nodes.map(node => {
const mappedType = WorkflowApp.PREVIEW_NODE_TYPE_MAP[node.type];
if (mappedType) {
return { ...node, type: mappedType };
}
return node;
});
return mappedWorkflow;
}
async loadCanvasPreview() {
if (!this.currentWorkflow) return;
const isVisible = !this.elements.canvasSection.classList.contains('hidden');
try {
this.elements.canvasViewer.innerHTML = '<div class="canvas-loading">Loading canvas preview...</div>';
if (isVisible) {
this.elements.canvasSection.classList.add('hidden');
this.elements.viewCanvasBtn.textContent = '🎨 View Canvas';
} else {
try {
this.elements.canvasViewer.innerHTML = '<div class="canvas-loading">Loading canvas preview...</div>';
this.elements.canvasSection.classList.remove('hidden');
this.elements.viewCanvasBtn.textContent = '🎨 Hide Canvas';
// Fetch the workflow JSON
const data = await this.apiCall(`/workflows/${this.currentWorkflow.filename}`);
const rawWorkflowJson = data.raw_json;
// Fetch the workflow JSON
const data = await this.apiCall(`/workflows/${this.currentWorkflow.filename}`);
const workflowJson = data.raw_json;
// Store raw JSON for Edit with AI and JSON view
this.currentJsonData = JSON.stringify(rawWorkflowJson, null, 2);
this.rawWorkflowJson = rawWorkflowJson;
// Detect current theme
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
// Map FlowEngine node types to official n8n nodes for proper icons
const workflowJson = this.mapNodeTypesForPreview(rawWorkflowJson);
// Create the n8n-demo web component
const n8nDemo = document.createElement('n8n-demo');
n8nDemo.setAttribute('workflow', JSON.stringify(workflowJson));
n8nDemo.setAttribute('theme', currentTheme);
n8nDemo.setAttribute('frame', 'false');
// Detect current theme
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
// Clear and add the component
this.elements.canvasViewer.innerHTML = '';
this.elements.canvasViewer.appendChild(n8nDemo);
// Create the n8n-demo web component
const n8nDemo = document.createElement('n8n-demo');
n8nDemo.setAttribute('workflow', JSON.stringify(workflowJson));
n8nDemo.setAttribute('theme', currentTheme);
n8nDemo.setAttribute('frame', 'false');
n8nDemo.setAttribute('collapseformobile', 'false');
n8nDemo.setAttribute('zoomtofit', 'true');
} catch (error) {
this.elements.canvasViewer.innerHTML = `
<div class="canvas-error">
<div>
<div style="font-size: 2rem; margin-bottom: 0.5rem;">⚠️</div>
<div>Error loading canvas preview</div>
<div style="font-size: 0.875rem; margin-top: 0.5rem; opacity: 0.8;">${this.escapeHtml(error.message)}</div>
</div>
// Clear and add the component
this.elements.canvasViewer.innerHTML = '';
this.elements.canvasViewer.appendChild(n8nDemo);
} catch (error) {
this.elements.canvasViewer.innerHTML = `
<div class="canvas-error">
<div>
<div style="font-size: 2rem; margin-bottom: 0.5rem;">⚠️</div>
<div>Error loading canvas preview</div>
<div style="font-size: 0.875rem; margin-top: 0.5rem; opacity: 0.8;">${this.escapeHtml(error.message)}</div>
</div>
`;
}
</div>
`;
}
}
async toggleJsonView() {
openEditWithAI() {
if (!this.rawWorkflowJson || !this.currentWorkflow) {
alert('Please wait for the workflow to load');
return;
}
try {
// Encode workflow JSON as base64
const jsonString = JSON.stringify(this.rawWorkflowJson);
const base64 = btoa(unescape(encodeURIComponent(jsonString)));
const name = encodeURIComponent(this.currentWorkflow.name);
// Open FlowEngine import page with encoded workflow
const url = `https://flowengine.cloud/import?data=${base64}&name=${name}`;
window.open(url, '_blank');
} catch (error) {
console.error('Failed to open Edit with AI:', error);
alert('Failed to open Edit with AI. The workflow may be too large.');
}
}
toggleJsonView() {
if (!this.currentWorkflow) return;
const isVisible = !this.elements.jsonSection.classList.contains('hidden');
if (isVisible) {
this.elements.jsonSection.classList.add('hidden');
this.elements.viewJsonBtn.textContent = '📄 View JSON';
this.elements.viewJsonBtn.textContent = '📄 JSON';
this.elements.viewJsonBtn.classList.remove('active');
} else {
try {
this.elements.jsonViewer.textContent = 'Loading...';
this.elements.jsonSection.classList.remove('hidden');
this.elements.viewJsonBtn.textContent = '📄 Hide JSON';
const data = await this.apiCall(`/workflows/${this.currentWorkflow.filename}`);
const jsonString = JSON.stringify(data.raw_json, null, 2);
this.currentJsonData = jsonString;
this.elements.jsonViewer.textContent = jsonString;
} catch (error) {
this.elements.jsonViewer.textContent = 'Error loading JSON: ' + error.message;
this.currentJsonData = null;
this.elements.jsonSection.classList.remove('hidden');
this.elements.viewJsonBtn.textContent = '📄 Hide';
this.elements.viewJsonBtn.classList.add('active');
// JSON is already loaded by loadCanvasPreview
if (this.currentJsonData) {
this.elements.jsonViewer.textContent = this.currentJsonData;
}
}
}