feat: improve block-cloud

This commit is contained in:
gchust 2025-06-23 18:51:18 +08:00
parent 82c8ff766d
commit b8ab09f68c
7 changed files with 839 additions and 192 deletions

View File

@ -1,49 +1,423 @@
# Cloud Component Block Plugin # Cloud Component Block Plugin
A NocoBase plugin that enables creating cloud component blocks that can load external JavaScript libraries and CSS files, then render components using custom adapter code. A simplified NocoBase plugin that enables creating cloud component blocks using custom JavaScript execution code.
## Features ## Features
- **Dynamic Library Loading**: Load external JavaScript libraries via CDN using app.requirejs - **Custom Code Execution**: Write and execute custom JavaScript code within the component
- **CSS Support**: Load external CSS files for styling - **External Library Loading**: Load external JavaScript libraries using requirejs
- **Custom Adapter Code**: Write custom JavaScript code to render components using the loaded libraries - **CSS Loading**: Load external CSS files using the loadCSS helper function
- **DOM Manipulation**: Direct access to the component's DOM element for rendering
- **Flow Page Integration**: Automatically appears in the "Add Block" dropdown in flow pages - **Flow Page Integration**: Automatically appears in the "Add Block" dropdown in flow pages
- **CodeMirror Editor**: Advanced code editor with syntax highlighting, smart auto-completion, and JavaScript language support
- **Context-Aware Autocomplete**: Intelligent suggestions for available variables (element, ctx, model, requirejs, requireAsync, loadCSS)
- **Code Snippets**: Pre-built templates for common operations like loading libraries, creating elements, and async operations
- **Simple Configuration**: Single-step configuration with execution code
## Configuration ## Configuration
Each cloud component block has three main configurations: Each cloud component block has one main configuration:
1. **JS CDN URL**: The URL to the JavaScript library (e.g., `https://cdn.jsdelivr.net/npm/echarts@5.4.2/dist/echarts.min.js`) 1. **Execution Code**: Custom JavaScript code that will be executed to render the component
2. **CSS URL** (Optional): The URL to the CSS file for styling
3. **Adapter Code**: Custom JavaScript code that uses the loaded library to render components
## Example Usage ## Example Usage
### ECharts Example ### 1. ECharts Data Visualization
**JS CDN URL**: `https://cdn.jsdelivr.net/npm/echarts@5.4.2/dist/echarts.min.js` **Execution Code**:
**Library Name**: `echarts`
**Adapter Code**:
```javascript ```javascript
// Available variables: // Load ECharts library
// - element: The DOM element to render into requirejs.config({
// - echarts: The loaded ECharts library paths: {
// - ctx: Flow context 'echarts': 'https://cdn.jsdelivr.net/npm/echarts@5.4.2/dist/echarts.min'
// - model: Current model instance }
});
const chart = echarts.init(element); const echarts = await requireAsync('echarts');
// Initialize chart with responsive design
const chart = echarts.init(element, null, { renderer: 'svg' });
// Sales data visualization
const option = { const option = {
title: { text: 'Cloud Component Demo' }, title: {
tooltip: {}, text: 'Monthly Sales Report',
xAxis: { data: ['A', 'B', 'C', 'D', 'E'] }, left: 'center',
yAxis: {}, textStyle: { color: '#333', fontSize: 18 }
},
tooltip: {
trigger: 'axis',
formatter: '{b}: ${c}'
},
grid: {
left: '10%',
right: '10%',
bottom: '15%'
},
xAxis: {
type: 'category',
data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
axisLabel: { color: '#666' }
},
yAxis: {
type: 'value',
axisLabel: { color: '#666', formatter: '${value}K' }
},
series: [{ series: [{
name: 'Demo', name: 'Sales',
type: 'bar', type: 'line',
data: [5, 20, 36, 10, 10] smooth: true,
data: [120, 132, 101, 134, 90, 230],
lineStyle: { color: '#1890ff', width: 3 },
areaStyle: { color: 'rgba(24, 144, 255, 0.1)' }
}] }]
}; };
chart.setOption(option); chart.setOption(option);
// Make chart responsive
window.addEventListener('resize', () => chart.resize());
```
### 2. Chart.js Interactive Dashboard
**Execution Code**:
```javascript
// Load Chart.js library
requirejs.config({
paths: {
'chart': 'https://cdn.jsdelivr.net/npm/chart.js@4.3.0/dist/chart.umd'
}
});
const Chart = await requireAsync('chart');
// Create canvas element
const canvas = document.createElement('canvas');
canvas.width = 400;
canvas.height = 300;
element.appendChild(canvas);
// Create interactive pie chart
new Chart(canvas, {
type: 'doughnut',
data: {
labels: ['Desktop', 'Mobile', 'Tablet', 'Other'],
datasets: [{
data: [45, 35, 15, 5],
backgroundColor: [
'#FF6384',
'#36A2EB',
'#FFCE56',
'#4BC0C0'
],
borderWidth: 2,
borderColor: '#fff'
}]
},
options: {
responsive: true,
plugins: {
title: {
display: true,
text: 'Traffic Sources Distribution',
font: { size: 16 }
},
legend: {
position: 'bottom'
}
},
animation: {
animateRotate: true,
duration: 2000
}
}
});
```
### 3. Swiper.js Image Gallery
**Execution Code**:
```javascript
// Load Swiper CSS and JS
await loadCSS('https://cdn.jsdelivr.net/npm/swiper@10/swiper-bundle.min.css');
requirejs.config({
paths: {
'swiper': 'https://cdn.jsdelivr.net/npm/swiper@10/swiper-bundle.min'
}
});
const Swiper = await requireAsync('swiper');
// Create gallery HTML
element.innerHTML = `
<div class="swiper" style="width: 100%; height: 400px;">
<div class="swiper-wrapper">
<div class="swiper-slide">
<img src="https://picsum.photos/600/400?random=1" style="width: 100%; height: 100%; object-fit: cover;" />
</div>
<div class="swiper-slide">
<img src="https://picsum.photos/600/400?random=2" style="width: 100%; height: 100%; object-fit: cover;" />
</div>
<div class="swiper-slide">
<img src="https://picsum.photos/600/400?random=3" style="width: 100%; height: 100%; object-fit: cover;" />
</div>
<div class="swiper-slide">
<img src="https://picsum.photos/600/400?random=4" style="width: 100%; height: 100%; object-fit: cover;" />
</div>
</div>
<div class="swiper-pagination"></div>
<div class="swiper-button-next"></div>
<div class="swiper-button-prev"></div>
</div>
`;
// Initialize Swiper with advanced features
new Swiper('.swiper', {
loop: true,
autoplay: {
delay: 3000,
disableOnInteraction: false,
},
effect: 'coverflow',
grabCursor: true,
centeredSlides: true,
slidesPerView: 'auto',
coverflowEffect: {
rotate: 50,
stretch: 0,
depth: 100,
modifier: 1,
slideShadows: true,
},
pagination: {
el: '.swiper-pagination',
clickable: true,
},
navigation: {
nextEl: '.swiper-button-next',
prevEl: '.swiper-button-prev',
},
});
```
### 4. AOS (Animate On Scroll) Effects
**Execution Code**:
```javascript
// Load AOS CSS and JS
await loadCSS('https://cdn.jsdelivr.net/npm/aos@2.3.4/dist/aos.css');
requirejs.config({
paths: {
'aos': 'https://cdn.jsdelivr.net/npm/aos@2.3.4/dist/aos'
}
});
const AOS = await requireAsync('aos');
// Create animated content
element.innerHTML = `
<div style="padding: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; min-height: 500px;">
<h1 data-aos="fade-up" data-aos-duration="1000" style="text-align: center; margin-bottom: 40px;">
Animated Dashboard
</h1>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px;">
<div data-aos="flip-left" data-aos-delay="100"
style="background: rgba(255,255,255,0.1); padding: 20px; border-radius: 10px; backdrop-filter: blur(10px);">
<h3>Total Users</h3>
<p style="font-size: 2em; font-weight: bold;">12,345</p>
</div>
<div data-aos="flip-left" data-aos-delay="200"
style="background: rgba(255,255,255,0.1); padding: 20px; border-radius: 10px; backdrop-filter: blur(10px);">
<h3>Revenue</h3>
<p style="font-size: 2em; font-weight: bold;">$98,765</p>
</div>
<div data-aos="flip-left" data-aos-delay="300"
style="background: rgba(255,255,255,0.1); padding: 20px; border-radius: 10px; backdrop-filter: blur(10px);">
<h3>Growth Rate</h3>
<p style="font-size: 2em; font-weight: bold;">+23%</p>
</div>
</div>
<div data-aos="fade-up" data-aos-delay="400" style="margin-top: 40px; text-align: center;">
<button style="padding: 12px 24px; background: #fff; color: #667eea; border: none; border-radius: 25px; font-weight: bold; cursor: pointer;">
View Details
</button>
</div>
</div>
`;
// Initialize AOS
AOS.init({
duration: 1200,
easing: 'ease-in-out',
once: false,
mirror: true
});
```
### 5. Three.js 3D Scene
**Execution Code**:
```javascript
// Load Three.js library
requirejs.config({
paths: {
'three': 'https://cdn.jsdelivr.net/npm/three@0.155.0/build/three.min'
}
});
const THREE = await requireAsync('three');
// Create 3D scene
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, element.clientWidth / 400, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(element.clientWidth, 400);
renderer.setClearColor(0x000000, 0.1);
element.appendChild(renderer.domElement);
// Create rotating cube with gradient material
const geometry = new THREE.BoxGeometry(2, 2, 2);
const material = new THREE.MeshPhongMaterial({
color: 0x4cc9f0,
transparent: true,
opacity: 0.8
});
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
// Add lighting
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 5, 5);
scene.add(light);
const ambientLight = new THREE.AmbientLight(0x404040, 0.5);
scene.add(ambientLight);
// Position camera
camera.position.z = 5;
// Animation loop
function animate() {
requestAnimationFrame(animate);
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
renderer.render(scene, camera);
}
animate();
// Handle resize
const resizeObserver = new ResizeObserver(() => {
const width = element.clientWidth;
camera.aspect = width / 400;
camera.updateProjectionMatrix();
renderer.setSize(width, 400);
});
resizeObserver.observe(element);
```
### 6. GSAP Advanced Animations
**Execution Code**:
```javascript
// Load GSAP library
requirejs.config({
paths: {
'gsap': 'https://cdn.jsdelivr.net/npm/gsap@3.12.2/dist/gsap.min'
}
});
const gsap = await requireAsync('gsap');
// Create animated interface
element.innerHTML = `
<div style="background: #1a1a1a; padding: 40px; border-radius: 15px; overflow: hidden; position: relative;">
<div class="bg-animation" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; opacity: 0.1;">
<div class="particle" style="position: absolute; width: 4px; height: 4px; background: #00ff88; border-radius: 50%;"></div>
<div class="particle" style="position: absolute; width: 6px; height: 6px; background: #0088ff; border-radius: 50%;"></div>
<div class="particle" style="position: absolute; width: 3px; height: 3px; background: #ff0088; border-radius: 50%;"></div>
</div>
<h1 class="main-title" style="color: white; text-align: center; font-size: 3em; margin-bottom: 30px; opacity: 0; transform: translateY(50px);">
GSAP Animation
</h1>
<div class="stats-container" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px;">
<div class="stat-box" style="background: linear-gradient(45deg, #00ff88, #00cc6a); padding: 20px; border-radius: 10px; text-align: center; transform: scale(0);">
<div class="stat-number" style="font-size: 2.5em; font-weight: bold; color: white;">0</div>
<div style="color: rgba(255,255,255,0.8);">Active Users</div>
</div>
<div class="stat-box" style="background: linear-gradient(45deg, #0088ff, #0066cc); padding: 20px; border-radius: 10px; text-align: center; transform: scale(0);">
<div class="stat-number" style="font-size: 2.5em; font-weight: bold; color: white;">0</div>
<div style="color: rgba(255,255,255,0.8);">Total Sales</div>
</div>
<div class="stat-box" style="background: linear-gradient(45deg, #ff0088, #cc0066); padding: 20px; border-radius: 10px; text-align: center; transform: scale(0);">
<div class="stat-number" style="font-size: 2.5em; font-weight: bold; color: white;">0</div>
<div style="color: rgba(255,255,255,0.8);">Growth %</div>
</div>
</div>
</div>
`;
// GSAP Timeline Animation
const tl = gsap.timeline();
// Animate particles in background
gsap.to('.particle', {
duration: 3,
x: 'random(-200, 200)',
y: 'random(-100, 100)',
rotation: 'random(0, 360)',
repeat: -1,
yoyo: true,
ease: 'power2.inOut',
stagger: 0.5
});
// Main animation sequence
tl.to('.main-title', {
duration: 1,
opacity: 1,
y: 0,
ease: 'back.out(1.7)'
})
.to('.stat-box', {
duration: 0.8,
scale: 1,
stagger: 0.2,
ease: 'back.out(1.7)'
}, '-=0.5')
.to('.stat-number', {
duration: 2,
innerHTML: (i) => [1234, 5678, 89][i],
snap: { innerHTML: 1 },
stagger: 0.3,
ease: 'power2.out'
}, '-=0.5');
// Hover effects
element.querySelectorAll('.stat-box').forEach(box => {
box.addEventListener('mouseenter', () => {
gsap.to(box, { duration: 0.3, scale: 1.05, y: -5 });
});
box.addEventListener('mouseleave', () => {
gsap.to(box, { duration: 0.3, scale: 1, y: 0 });
});
});
``` ```
## Installation ## Installation
@ -63,9 +437,8 @@ The plugin follows the standard NocoBase plugin structure:
## Architecture ## Architecture
The plugin extends the `BlockFlowModel` class and implements a default flow with two steps: The plugin extends the `BlockFlowModel` class and implements a simplified default flow with a single step:
1. **loadLibrary**: Configures requirejs and loads the external JavaScript/CSS 1. **executionStep**: Executes the custom JavaScript code to render the component
2. **setupComponent**: Executes the adapter code to render the component
The plugin automatically registers with the flow engine and appears in the flow page's "Add Block" dropdown. The plugin automatically registers with the flow engine and appears in the flow page's "Add Block" dropdown.

View File

@ -2,19 +2,25 @@
"name": "@nocobase/plugin-block-cloud", "name": "@nocobase/plugin-block-cloud",
"displayName": "Block: Cloud Component", "displayName": "Block: Cloud Component",
"displayName.zh-CN": "区块:云组件", "displayName.zh-CN": "区块:云组件",
"description": "Create cloud component blocks that load external JS/CSS and render components via adapter code.", "description": "Create cloud component blocks that execute custom JavaScript code with external library loading support.",
"description.zh-CN": "创建云组件区块,加载外部JS/CSS并通过适配器代码渲染组件。", "description.zh-CN": "创建云组件区块,通过执行自定义JavaScript代码进行渲染支持加载外部库。",
"version": "1.8.0-beta.4", "version": "1.8.0-beta.4",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"main": "./dist/server/index.js", "main": "./dist/server/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"devDependencies": { "devDependencies": {
"@ant-design/icons": "5.x", "@ant-design/icons": "5.x",
"@codemirror/lang-javascript": "^6.0.1",
"@codemirror/state": "^6.0.1",
"@codemirror/theme-one-dark": "^6.0.1",
"@formily/react": "2.x", "@formily/react": "2.x",
"@formily/shared": "2.x", "@formily/shared": "2.x",
"antd": "5.x", "antd": "5.x",
"codemirror": "^6.0.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-i18next": "^11.15.1" "react-i18next": "^11.15.1",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/view": "^6.37.2"
}, },
"peerDependencies": { "peerDependencies": {
"@nocobase/client": "workspace:*", "@nocobase/client": "workspace:*",

View File

@ -9,7 +9,8 @@
import { Card, Spin } from 'antd'; import { Card, Spin } from 'antd';
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import { BlockFlowModel } from '@nocobase/client'; import { BlockModel } from '@nocobase/client';
import { CodeEditor } from './CodeEditor';
function waitForRefCallback<T extends HTMLElement>(ref: React.RefObject<T>, cb: (el: T) => void, timeout = 3000) { function waitForRefCallback<T extends HTMLElement>(ref: React.RefObject<T>, cb: (el: T) => void, timeout = 3000) {
const start = Date.now(); const start = Date.now();
@ -21,7 +22,7 @@ function waitForRefCallback<T extends HTMLElement>(ref: React.RefObject<T>, cb:
check(); check();
} }
export class CloudBlockFlowModel extends BlockFlowModel { export class CloudBlockFlowModel extends BlockModel {
ref = createRef<HTMLDivElement>(); ref = createRef<HTMLDivElement>();
render() { render() {
@ -49,6 +50,9 @@ export class CloudBlockFlowModel extends BlockFlowModel {
} }
} }
// Export CodeEditor for external use
export { CodeEditor };
CloudBlockFlowModel.define({ CloudBlockFlowModel.define({
title: 'Cloud Component', title: 'Cloud Component',
group: 'Content', group: 'Content',
@ -57,27 +61,10 @@ CloudBlockFlowModel.define({
use: 'CloudBlockFlowModel', use: 'CloudBlockFlowModel',
stepParams: { stepParams: {
default: { default: {
loadLibrary: { executionStep: {
jsUrl: 'https://cdn.jsdelivr.net/npm/echarts@5.4.2/dist/echarts.min.js', code: `
cssUrl: '', // Example execution code
libraryName: 'echarts', element.innerHTML = '<h3>Cloud Component Demo</h3><p>This is a simplified cloud component.</p>';
},
setupComponent: {
adapterCode: `
// Example adapter code for ECharts
const chart = echarts.init(element);
const option = {
title: { text: 'Cloud Component Demo' },
tooltip: {},
xAxis: { data: ['A', 'B', 'C', 'D', 'E'] },
yAxis: {},
series: [{
name: 'Demo',
type: 'bar',
data: [5, 20, 36, 10, 10]
}]
};
chart.setOption(option);
`.trim(), `.trim(),
}, },
}, },
@ -89,137 +76,102 @@ CloudBlockFlowModel.registerFlow({
key: 'default', key: 'default',
auto: true, auto: true,
steps: { steps: {
loadLibrary: { executionStep: {
uiSchema: { uiSchema: {
jsUrl: { code: {
type: 'string', type: 'string',
title: 'JS CDN URL', title: 'Execution Code',
'x-component': 'Input', 'x-component': 'CodeEditor',
'x-component-props': { 'x-component-props': {
placeholder: 'https://cdn.jsdelivr.net/npm/library@version/dist/library.min.js', height: '400px',
}, theme: 'light',
}, placeholder: `// Write your execution code here
cssUrl: { // Available variables:
type: 'string', // - element: The DOM element to render into
title: 'CSS URL (Optional)', // - ctx: Flow context
'x-component': 'Input', // - model: Current model instance
'x-component-props': { // - requirejs: Function to load external JavaScript libraries (callback style)
placeholder: 'https://cdn.jsdelivr.net/npm/library@version/dist/library.min.css', // - requireAsync: Function to load external JavaScript libraries (async/await style)
}, // - loadCSS: Function to load external CSS files
},
libraryName: { element.innerHTML = '<h3>Hello World</h3><p>This is a cloud component.</p>';`,
type: 'string',
title: 'Library Name',
'x-component': 'Input',
'x-component-props': {
placeholder: 'echarts',
}, },
}, },
}, },
defaultParams: { defaultParams: {
jsUrl: 'https://cdn.jsdelivr.net/npm/echarts@5.4.2/dist/echarts.min.js', code: `
cssUrl: '', // Example execution code
libraryName: 'echarts', element.innerHTML = '<h3>Cloud Component Demo</h3><p>This is a simplified cloud component.</p>';
// You can also use async/await
// await new Promise(resolve => setTimeout(resolve, 1000));
// element.innerHTML += '<br>Async operation completed!';
`.trim(),
}, },
async handler(ctx: any, params: any) { async handler(ctx: any, params: any) {
ctx.model.setProps('loading', true); ctx.model.setProps('loading', true);
ctx.model.setProps('error', null); ctx.model.setProps('error', null);
try {
// Configure requirejs paths
const paths: Record<string, string> = {};
paths[params.libraryName] = params.jsUrl.replace(/\.js$/, '');
// Load CSS if provided
if (params.cssUrl) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = params.cssUrl;
document.head.appendChild(link);
}
// Configure requirejs
// const requireAsync = async (mod: string): Promise<any> => {
// return new Promise((resolve, reject) => {
// ctx.app.requirejs.requirejs([mod], (arg: any) => resolve(arg), reject);
// });
// };
ctx.app.requirejs.requirejs.config({ paths });
await ctx.globals.requireAsync(params.libraryName);
// Return the library name for the next step
return { libraryName: params.libraryName };
} catch (error: any) {
ctx.model.setProps('error', error.message);
ctx.model.setProps('loading', false);
throw error;
}
},
},
setupComponent: {
uiSchema: {
adapterCode: {
type: 'string',
title: 'Adapter Code',
'x-component': 'Input.TextArea',
'x-component-props': {
autoSize: { minRows: 10, maxRows: 20 },
placeholder: `// Write your adapter code here
// Available variables:
// - element: The DOM element to render into
// - library: The loaded library (e.g., echarts)
// - ctx: Flow context
// - model: Current model instance
const chart = library.init(element);
chart.setOption({
// your chart configuration
});`,
},
},
},
defaultParams: {
adapterCode: `
// Example adapter code for ECharts
const chart = echarts.init(element);
const option = {
title: { text: 'Cloud Component Demo' },
tooltip: {},
xAxis: { data: ['A', 'B', 'C', 'D', 'E'] },
yAxis: {},
series: [{
name: 'Demo',
type: 'bar',
data: [5, 20, 36, 10, 10]
}]
};
chart.setOption(option);
`.trim(),
},
async handler(ctx: any, params: any) {
const { libraryName } = ctx.stepResults.loadLibrary || {};
if (!libraryName) {
ctx.model.setProps('error', 'Library name not found');
ctx.model.setProps('loading', false);
return;
}
waitForRefCallback(ctx.model.ref, async (element: HTMLElement) => { waitForRefCallback(ctx.model.ref, async (element: HTMLElement) => {
try { try {
// Load the library // Get requirejs from app context
const library = await ctx.globals.requireAsync(libraryName); const requirejs = ctx.app?.requirejs?.requirejs;
// Create a safe execution context for the adapter code // Helper function to load CSS
const adapterFunction = new Function('element', 'library', libraryName, 'ctx', 'model', params.adapterCode); const loadCSS = (url: string): Promise<void> => {
return new Promise((resolve, reject) => {
// Check if CSS is already loaded
const existingLink = document.querySelector(`link[href="${url}"]`);
if (existingLink) {
resolve();
return;
}
// Execute the adapter code const link = document.createElement('link');
await adapterFunction(element, library, library, ctx, ctx.model); link.rel = 'stylesheet';
link.href = url;
link.onload = () => resolve();
link.onerror = () => reject(new Error(`Failed to load CSS: ${url}`));
document.head.appendChild(link);
});
};
// Helper function for async requirejs
const requireAsync = (modules: string | string[]): Promise<any> => {
return new Promise((resolve, reject) => {
if (!requirejs) {
reject(new Error('requirejs is not available'));
return;
}
const moduleList = Array.isArray(modules) ? modules : [modules];
requirejs(
moduleList,
(...args: any[]) => {
// If single module, return the module directly
// If multiple modules, return array
resolve(moduleList.length === 1 ? args[0] : args);
},
reject,
);
});
};
// Create a safe execution context for the code (as async function)
// Wrap user code in an async function
const wrappedCode = `
return (async function(element, ctx, model, requirejs, requireAsync, loadCSS) {
${params.code}
}).apply(this, arguments);
`;
const executionFunction = new Function(wrappedCode);
// Execute the code
await executionFunction(element, ctx, ctx.model, requirejs, requireAsync, loadCSS);
ctx.model.setProps('loading', false); ctx.model.setProps('loading', false);
} catch (error: any) { } catch (error: any) {
console.error('Cloud component adapter error:', error); console.error('Cloud component execution error:', error);
ctx.model.setProps('error', error.message); ctx.model.setProps('error', error.message);
ctx.model.setProps('loading', false); ctx.model.setProps('loading', false);
} }

View File

@ -0,0 +1,326 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useRef, useEffect } from 'react';
import { connect, mapProps } from '@formily/react';
// CodeMirror imports
import { EditorView, basicSetup } from 'codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorState } from '@codemirror/state';
import { autocompletion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
// 自定义自动补全函数
const createCustomCompletion = () => {
const contextVariables = [
{
label: 'element',
type: 'variable',
info: 'The DOM element to render into',
detail: 'HTMLElement',
boost: 99,
},
{
label: 'ctx',
type: 'variable',
info: 'Flow context object',
detail: 'FlowContext',
boost: 98,
},
{
label: 'model',
type: 'variable',
info: 'Current model instance',
detail: 'FlowModel',
boost: 97,
},
{
label: 'requirejs',
type: 'function',
info: 'Function to load external JavaScript libraries (callback style)',
detail: '(modules: string[], callback: Function) => void',
boost: 96,
},
{
label: 'requireAsync',
type: 'function',
info: 'Function to load external JavaScript libraries (async/await style)',
detail: '(modules: string | string[]) => Promise<any>',
boost: 95,
},
{
label: 'loadCSS',
type: 'function',
info: 'Function to load external CSS files',
detail: '(url: string) => Promise<void>',
boost: 94,
},
];
// 常用的 DOM 操作和 JS API
const commonAPIs = [
{
label: 'element.innerHTML',
type: 'property',
info: 'Set or get the HTML content inside the element',
detail: 'string',
boost: 90,
},
{
label: 'element.textContent',
type: 'property',
info: 'Set or get the text content of the element',
detail: 'string',
boost: 89,
},
{
label: 'element.appendChild',
type: 'method',
info: 'Append a child node to the element',
detail: '(node: Node) => Node',
boost: 88,
},
{
label: 'element.setAttribute',
type: 'method',
info: 'Set an attribute on the element',
detail: '(name: string, value: string) => void',
boost: 87,
},
{
label: 'document.createElement',
type: 'method',
info: 'Create a new HTML element',
detail: '(tagName: string) => HTMLElement',
boost: 85,
},
];
// 代码片段模板
const codeSnippets = [
{
label: 'load-library-async',
type: 'snippet',
info: 'Load external library using requireAsync',
detail: 'Template',
boost: 80,
apply: `requirejs.config({
paths: {
'libraryName': 'https://cdn.jsdelivr.net/npm/library@version/dist/library.min'
}
});
const library = await requireAsync('libraryName');`,
},
{
label: 'load-css',
type: 'snippet',
info: 'Load external CSS file',
detail: 'Template',
boost: 79,
apply: `await loadCSS('https://example.com/styles.css');`,
},
{
label: 'create-element',
type: 'snippet',
info: 'Create and append HTML element',
detail: 'Template',
boost: 78,
apply: `const newElement = document.createElement('div');
newElement.innerHTML = 'Hello World';
element.appendChild(newElement);`,
},
{
label: 'async-example',
type: 'snippet',
info: 'Async operation with loading state',
detail: 'Template',
boost: 77,
apply: `element.innerHTML = '<div>Loading...</div>';
// Simulate async operation
await new Promise(resolve => setTimeout(resolve, 1000));
element.innerHTML = '<h3>Content Loaded!</h3>';`,
},
];
return (context: CompletionContext): CompletionResult | null => {
const word = context.matchBefore(/\w*/);
if (!word) return null;
const from = word.from;
const to = word.to;
// 合并所有的补全选项
const allCompletions = [...contextVariables, ...commonAPIs, ...codeSnippets];
// 过滤匹配的选项
const options = allCompletions
.filter((item) => item.label.toLowerCase().includes(word.text.toLowerCase()))
.map((item) => ({
label: item.label,
type: item.type,
info: item.info,
detail: item.detail,
boost: item.boost,
apply: item.apply, // 对于代码片段,包含要插入的代码
}));
return {
from,
to,
options,
};
};
};
interface CodeEditorProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
height?: string | number;
theme?: 'light' | 'dark';
readonly?: boolean;
}
const CodeEditorComponent: React.FC<CodeEditorProps> = ({
value = '',
onChange,
placeholder = '',
height = '300px',
theme = 'light',
readonly = false,
}) => {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
useEffect(() => {
if (!editorRef.current) return;
const extensions = [
basicSetup,
javascript(),
autocompletion({
override: [createCustomCompletion()],
closeOnBlur: false,
activateOnTyping: true,
}),
EditorView.updateListener.of((update) => {
if (update.docChanged && onChange && !readonly) {
const newValue = update.state.doc.toString();
onChange(newValue);
}
}),
EditorView.theme({
'&': {
height: typeof height === 'string' ? height : `${height}px`,
},
'.cm-editor': {
height: '100%',
},
'.cm-scroller': {
fontFamily: '"Fira Code", "Monaco", "Menlo", "Ubuntu Mono", monospace',
},
'.cm-tooltip-autocomplete': {
border: '1px solid #d9d9d9',
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
},
}),
];
if (theme === 'dark') {
extensions.push(oneDark);
}
if (placeholder) {
extensions.push(
EditorView.domEventHandlers({
focus: (event, view) => {
if (view.state.doc.length === 0) {
view.dispatch({
changes: { from: 0, insert: '' },
selection: { anchor: 0 },
});
}
},
}),
);
}
const state = EditorState.create({
doc: value,
extensions,
});
const view = new EditorView({
state,
parent: editorRef.current,
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
}, [theme, height, placeholder, readonly]);
// Update editor content when value prop changes
useEffect(() => {
if (viewRef.current && viewRef.current.state.doc.toString() !== value) {
viewRef.current.dispatch({
changes: {
from: 0,
to: viewRef.current.state.doc.length,
insert: value,
},
});
}
}, [value]);
return (
<div
style={{
border: '1px solid #d9d9d9',
borderRadius: '6px',
overflow: 'hidden',
}}
>
<div ref={editorRef} />
{placeholder && !value && (
<div
style={{
position: 'absolute',
top: '12px',
left: '12px',
color: '#999',
pointerEvents: 'none',
fontSize: '14px',
}}
>
{placeholder}
</div>
)}
</div>
);
};
// Connect with Formily
export const CodeEditor = connect(
CodeEditorComponent,
mapProps({
value: 'value',
readOnly: 'readonly',
}),
);
export default CodeEditor;

View File

@ -9,9 +9,15 @@
import { Plugin } from '@nocobase/client'; import { Plugin } from '@nocobase/client';
import { CloudBlockFlowModel } from './CloudBlockFlowModel'; import { CloudBlockFlowModel } from './CloudBlockFlowModel';
import { CodeEditor } from './CodeEditor';
export class PluginBlockCloudClient extends Plugin { export class PluginBlockCloudClient extends Plugin {
async load() { async load() {
// Register CodeEditor component to flowSettings
this.flowEngine.flowSettings.registerComponents({
CodeEditor,
});
// Register the CloudBlockFlowModel // Register the CloudBlockFlowModel
this.flowEngine.registerModels({ CloudBlockFlowModel }); this.flowEngine.registerModels({ CloudBlockFlowModel });

View File

@ -1,17 +1,9 @@
{ {
"Cloud Component": "Cloud Component", "Cloud Component": "Cloud Component",
"Cloud Block": "Cloud Block", "Cloud Block": "Cloud Block",
"JS CDN URL": "JS CDN URL", "Execution Code": "Execution Code",
"CSS URL": "CSS URL", "Execution Step": "Execution Step",
"Library Name": "Library Name", "Enter the execution code to render the component": "Enter the execution code to render the component",
"Adapter Code": "Adapter Code", "Failed to execute code": "Failed to execute code",
"Load Library": "Load Library", "Component executed successfully": "Component executed successfully"
"Setup Component": "Setup Component",
"Enter the CDN URL for the JavaScript library": "Enter the CDN URL for the JavaScript library",
"Enter the CSS URL (optional)": "Enter the CSS URL (optional)",
"Enter the global library name": "Enter the global library name",
"Enter the adapter code to initialize the component": "Enter the adapter code to initialize the component",
"Failed to load library": "Failed to load library",
"Failed to initialize component": "Failed to initialize component",
"Component loaded successfully": "Component loaded successfully"
} }

View File

@ -1,17 +1,9 @@
{ {
"Cloud Component": "云组件", "Cloud Component": "云组件",
"Cloud Block": "云组件区块", "Cloud Block": "云组件区块",
"JS CDN URL": "JS CDN 地址", "Execution Code": "执行代码",
"CSS URL": "CSS 地址", "Execution Step": "执行步骤",
"Library Name": "库名称", "Enter the execution code to render the component": "输入执行代码来渲染组件",
"Adapter Code": "适配器代码", "Failed to execute code": "执行代码失败",
"Load Library": "加载库", "Component executed successfully": "组件执行成功"
"Setup Component": "设置组件",
"Enter the CDN URL for the JavaScript library": "输入 JavaScript 库的 CDN 地址",
"Enter the CSS URL (optional)": "输入 CSS 地址(可选)",
"Enter the global library name": "输入全局库名称",
"Enter the adapter code to initialize the component": "输入适配器代码来初始化组件",
"Failed to load library": "加载库失败",
"Failed to initialize component": "初始化组件失败",
"Component loaded successfully": "组件加载成功"
} }