mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-07-02 03:02:19 +08:00
feat: improve block-cloud
This commit is contained in:
parent
82c8ff766d
commit
b8ab09f68c
@ -1,49 +1,423 @@
|
||||
# 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
|
||||
|
||||
- **Dynamic Library Loading**: Load external JavaScript libraries via CDN using app.requirejs
|
||||
- **CSS Support**: Load external CSS files for styling
|
||||
- **Custom Adapter Code**: Write custom JavaScript code to render components using the loaded libraries
|
||||
- **Custom Code Execution**: Write and execute custom JavaScript code within the component
|
||||
- **External Library Loading**: Load external JavaScript libraries using requirejs
|
||||
- **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
|
||||
- **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
|
||||
|
||||
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`)
|
||||
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
|
||||
1. **Execution Code**: Custom JavaScript code that will be executed to render the component
|
||||
|
||||
## Example Usage
|
||||
|
||||
### ECharts Example
|
||||
### 1. ECharts Data Visualization
|
||||
|
||||
**JS CDN URL**: `https://cdn.jsdelivr.net/npm/echarts@5.4.2/dist/echarts.min.js`
|
||||
**Library Name**: `echarts`
|
||||
**Adapter Code**:
|
||||
**Execution Code**:
|
||||
```javascript
|
||||
// Available variables:
|
||||
// - element: The DOM element to render into
|
||||
// - echarts: The loaded ECharts library
|
||||
// - ctx: Flow context
|
||||
// - model: Current model instance
|
||||
// Load ECharts library
|
||||
requirejs.config({
|
||||
paths: {
|
||||
'echarts': 'https://cdn.jsdelivr.net/npm/echarts@5.4.2/dist/echarts.min'
|
||||
}
|
||||
});
|
||||
|
||||
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 = {
|
||||
title: { text: 'Cloud Component Demo' },
|
||||
tooltip: {},
|
||||
xAxis: { data: ['A', 'B', 'C', 'D', 'E'] },
|
||||
yAxis: {},
|
||||
title: {
|
||||
text: 'Monthly Sales Report',
|
||||
left: 'center',
|
||||
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: [{
|
||||
name: 'Demo',
|
||||
type: 'bar',
|
||||
data: [5, 20, 36, 10, 10]
|
||||
name: 'Sales',
|
||||
type: 'line',
|
||||
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);
|
||||
|
||||
// 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
|
||||
@ -63,9 +437,8 @@ The plugin follows the standard NocoBase plugin structure:
|
||||
|
||||
## 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
|
||||
2. **setupComponent**: Executes the adapter code to render the component
|
||||
1. **executionStep**: Executes the custom JavaScript code to render the component
|
||||
|
||||
The plugin automatically registers with the flow engine and appears in the flow page's "Add Block" dropdown.
|
||||
|
@ -2,19 +2,25 @@
|
||||
"name": "@nocobase/plugin-block-cloud",
|
||||
"displayName": "Block: Cloud Component",
|
||||
"displayName.zh-CN": "区块:云组件",
|
||||
"description": "Create cloud component blocks that load external JS/CSS and render components via adapter code.",
|
||||
"description.zh-CN": "创建云组件区块,加载外部JS/CSS并通过适配器代码渲染组件。",
|
||||
"description": "Create cloud component blocks that execute custom JavaScript code with external library loading support.",
|
||||
"description.zh-CN": "创建云组件区块,通过执行自定义JavaScript代码进行渲染,支持加载外部库。",
|
||||
"version": "1.8.0-beta.4",
|
||||
"license": "AGPL-3.0",
|
||||
"main": "./dist/server/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"devDependencies": {
|
||||
"@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/shared": "2.x",
|
||||
"antd": "5.x",
|
||||
"codemirror": "^6.0.1",
|
||||
"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": {
|
||||
"@nocobase/client": "workspace:*",
|
||||
|
@ -9,7 +9,8 @@
|
||||
|
||||
import { Card, Spin } from 'antd';
|
||||
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) {
|
||||
const start = Date.now();
|
||||
@ -21,7 +22,7 @@ function waitForRefCallback<T extends HTMLElement>(ref: React.RefObject<T>, cb:
|
||||
check();
|
||||
}
|
||||
|
||||
export class CloudBlockFlowModel extends BlockFlowModel {
|
||||
export class CloudBlockFlowModel extends BlockModel {
|
||||
ref = createRef<HTMLDivElement>();
|
||||
|
||||
render() {
|
||||
@ -49,6 +50,9 @@ export class CloudBlockFlowModel extends BlockFlowModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Export CodeEditor for external use
|
||||
export { CodeEditor };
|
||||
|
||||
CloudBlockFlowModel.define({
|
||||
title: 'Cloud Component',
|
||||
group: 'Content',
|
||||
@ -57,27 +61,10 @@ CloudBlockFlowModel.define({
|
||||
use: 'CloudBlockFlowModel',
|
||||
stepParams: {
|
||||
default: {
|
||||
loadLibrary: {
|
||||
jsUrl: 'https://cdn.jsdelivr.net/npm/echarts@5.4.2/dist/echarts.min.js',
|
||||
cssUrl: '',
|
||||
libraryName: 'echarts',
|
||||
},
|
||||
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);
|
||||
executionStep: {
|
||||
code: `
|
||||
// Example execution code
|
||||
element.innerHTML = '<h3>Cloud Component Demo</h3><p>This is a simplified cloud component.</p>';
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
@ -89,137 +76,102 @@ CloudBlockFlowModel.registerFlow({
|
||||
key: 'default',
|
||||
auto: true,
|
||||
steps: {
|
||||
loadLibrary: {
|
||||
executionStep: {
|
||||
uiSchema: {
|
||||
jsUrl: {
|
||||
code: {
|
||||
type: 'string',
|
||||
title: 'JS CDN URL',
|
||||
'x-component': 'Input',
|
||||
title: 'Execution Code',
|
||||
'x-component': 'CodeEditor',
|
||||
'x-component-props': {
|
||||
placeholder: 'https://cdn.jsdelivr.net/npm/library@version/dist/library.min.js',
|
||||
},
|
||||
},
|
||||
cssUrl: {
|
||||
type: 'string',
|
||||
title: 'CSS URL (Optional)',
|
||||
'x-component': 'Input',
|
||||
'x-component-props': {
|
||||
placeholder: 'https://cdn.jsdelivr.net/npm/library@version/dist/library.min.css',
|
||||
},
|
||||
},
|
||||
libraryName: {
|
||||
type: 'string',
|
||||
title: 'Library Name',
|
||||
'x-component': 'Input',
|
||||
'x-component-props': {
|
||||
placeholder: 'echarts',
|
||||
height: '400px',
|
||||
theme: 'light',
|
||||
placeholder: `// Write your execution code here
|
||||
// Available variables:
|
||||
// - element: The DOM element to render into
|
||||
// - ctx: Flow context
|
||||
// - model: Current model instance
|
||||
// - requirejs: Function to load external JavaScript libraries (callback style)
|
||||
// - requireAsync: Function to load external JavaScript libraries (async/await style)
|
||||
// - loadCSS: Function to load external CSS files
|
||||
|
||||
element.innerHTML = '<h3>Hello World</h3><p>This is a cloud component.</p>';`,
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultParams: {
|
||||
jsUrl: 'https://cdn.jsdelivr.net/npm/echarts@5.4.2/dist/echarts.min.js',
|
||||
cssUrl: '',
|
||||
libraryName: 'echarts',
|
||||
code: `
|
||||
// Example execution code
|
||||
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) {
|
||||
ctx.model.setProps('loading', true);
|
||||
ctx.model.setProps('error', null);
|
||||
|
||||
waitForRefCallback(ctx.model.ref, async (element: HTMLElement) => {
|
||||
try {
|
||||
// Configure requirejs paths
|
||||
const paths: Record<string, string> = {};
|
||||
paths[params.libraryName] = params.jsUrl.replace(/\.js$/, '');
|
||||
// Get requirejs from app context
|
||||
const requirejs = ctx.app?.requirejs?.requirejs;
|
||||
|
||||
// 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);
|
||||
// Helper function to load CSS
|
||||
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;
|
||||
}
|
||||
|
||||
waitForRefCallback(ctx.model.ref, async (element: HTMLElement) => {
|
||||
try {
|
||||
// Load the library
|
||||
const library = await ctx.globals.requireAsync(libraryName);
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = url;
|
||||
link.onload = () => resolve();
|
||||
link.onerror = () => reject(new Error(`Failed to load CSS: ${url}`));
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
};
|
||||
|
||||
// Create a safe execution context for the adapter code
|
||||
const adapterFunction = new Function('element', 'library', libraryName, 'ctx', 'model', params.adapterCode);
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Execute the adapter code
|
||||
await adapterFunction(element, library, library, ctx, ctx.model);
|
||||
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);
|
||||
} 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('loading', false);
|
||||
}
|
||||
|
@ -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;
|
@ -9,9 +9,15 @@
|
||||
|
||||
import { Plugin } from '@nocobase/client';
|
||||
import { CloudBlockFlowModel } from './CloudBlockFlowModel';
|
||||
import { CodeEditor } from './CodeEditor';
|
||||
|
||||
export class PluginBlockCloudClient extends Plugin {
|
||||
async load() {
|
||||
// Register CodeEditor component to flowSettings
|
||||
this.flowEngine.flowSettings.registerComponents({
|
||||
CodeEditor,
|
||||
});
|
||||
|
||||
// Register the CloudBlockFlowModel
|
||||
this.flowEngine.registerModels({ CloudBlockFlowModel });
|
||||
|
||||
|
@ -1,17 +1,9 @@
|
||||
{
|
||||
"Cloud Component": "Cloud Component",
|
||||
"Cloud Block": "Cloud Block",
|
||||
"JS CDN URL": "JS CDN URL",
|
||||
"CSS URL": "CSS URL",
|
||||
"Library Name": "Library Name",
|
||||
"Adapter Code": "Adapter Code",
|
||||
"Load Library": "Load Library",
|
||||
"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"
|
||||
"Execution Code": "Execution Code",
|
||||
"Execution Step": "Execution Step",
|
||||
"Enter the execution code to render the component": "Enter the execution code to render the component",
|
||||
"Failed to execute code": "Failed to execute code",
|
||||
"Component executed successfully": "Component executed successfully"
|
||||
}
|
||||
|
@ -1,17 +1,9 @@
|
||||
{
|
||||
"Cloud Component": "云组件",
|
||||
"Cloud Block": "云组件区块",
|
||||
"JS CDN URL": "JS CDN 地址",
|
||||
"CSS URL": "CSS 地址",
|
||||
"Library Name": "库名称",
|
||||
"Adapter Code": "适配器代码",
|
||||
"Load Library": "加载库",
|
||||
"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": "组件加载成功"
|
||||
"Execution Code": "执行代码",
|
||||
"Execution Step": "执行步骤",
|
||||
"Enter the execution code to render the component": "输入执行代码来渲染组件",
|
||||
"Failed to execute code": "执行代码失败",
|
||||
"Component executed successfully": "组件执行成功"
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user