高级物料开发
高级物料开发
高级物料开发允许你通过 advanceCustom 配置项来深度定制组件在设计器中的行为,包括拖拽、选择、工具栏、右侧面板等各个方面。本文档将以 ReactGridLayout 组件为例,详细介绍如何使用这些高级特性。
什么是高级物料?
高级物料是指那些需要特殊编辑行为的组件,例如:
- 布局容器组件:需要自定义拖拽和放置逻辑
- 复杂交互组件:需要定制工具栏或选择框样式
- 特殊渲染组件:需要在编辑模式下包装或修改组件行为
- 响应式组件:需要根据断点动态调整布局
通过 advanceCustom 配置,你可以完全控制组件在设计器中的编辑体验。
advanceCustom 配置项概览
advanceCustom 提供了丰富的配置选项,主要包括:
生命周期钩子
onDragStart/onDragging/onDragEnd:拖拽生命周期onSelect:选中时触发onCopy:复制时触发onDelete:删除时触发onNewAdd:首次添加到画布时触发onDrop:放置到目标位置时触发
权限控制
canDragNode:控制节点是否可拖拽canDropNode:控制节点是否可被放置canAcceptNode:控制节点是否可接受子节点disableEditorDragDom:禁用编辑器默认的拖拽行为
视图定制
wrapComponent:包装组件,定制编辑模式下的行为toolbarViewRender:自定义工具栏视图selectRectViewRender:自定义选中框视图hoverRectViewRender:自定义悬停框视图dropViewRender:自定义放置预览视图ghostViewRender:自定义拖拽占位视图
面板配置
rightPanel:配置右侧属性面板的显示和定制autoGetDom:控制是否自动获取 DOM 元素
ReactGridLayout 示例
ReactGridLayout 是一个完整的高级物料示例,它实现了基于 GridStack 的响应式布局系统。让我们逐步分析它的实现。
组件结构
ReactGridLayout 包含两个主要组件:
- GridLayout:布局容器,管理整个网格系统
- GridItem:布局项,每个可拖拽的子元素
1. GridLayout 物料定义
import { CMaterialType } from '@chamn/model';import { snippets } from './snippets';import { LayoutWrap } from './edit/layoutWrap';import { useEffect, useState, useCallback, useRef } from 'react';import { EnginContext } from '@chamn/engine';import { DesignerPluginInstance } from '@chamn/engine/dist/plugins/Designer/type';import { GridStack } from 'gridstack';import { breakpoints } from './config';
export const ReactGridLayoutMeta: CMaterialType = { componentName: 'GridLayout', title: '高级布局画布', props: [ { name: 'breakpoints', title: 'Breakpoints', setters: [ { componentName: 'ArraySetter', initialValue: breakpoints, props: { collapse: { open: true, }, item: { initialValue: { w: 0, label: '' }, setters: [ { componentName: 'ShapeSetter', initialValue: { with: 0, c: 0, }, props: { collapse: { open: true, }, elements: [ { name: 'label', title: 'label', setters: ['StringSetter'], valueType: 'string', }, { name: 'w', title: 'width', setters: [ { componentName: 'NumberSetter', props: { suffix: 'px', }, }, ], valueType: 'number', }, ], }, }, ], }, }, }, ], valueType: 'array', }, ], isContainer: true, category: '高级布局', groupName: '内置组件', npm: { name: 'GridLayout', package: __PACKAGE_NAME__ || '', version: __PACKAGE_VERSION__, destructuring: true, exportName: 'GridLayout', }, snippets: snippets, advanceCustom: { autoGetDom: false, wrapComponent: (Comp, options) => { return (props: any) => { const [iframeWindow, setIframeWindow] = useState(); const designerRef = useRef<DesignerPluginInstance>(); useEffect(() => { const ctx: EnginContext = options.ctx; ctx.pluginManager.onPluginReadyOk('Designer').then((ins: DesignerPluginInstance) => { designerRef.current = ins; const win = ins.export.getDesignerWindow(); setIframeWindow(win as any); }); }, []);
const onGridMount = useCallback((grid: GridStack) => { grid.on('dragstart', () => { designerRef.current?.export.getLayoutRef().current?.banSelectNode(); }); grid.on('dragstop', (event) => { setTimeout(() => { designerRef.current?.export.getLayoutRef().current?.recoverSelectNode(); const nodeId = (event.target as any)?.getAttribute('data-grid-id'); designerRef.current?.export.getLayoutRef().current?.selectNode(nodeId); }, 0); }); }, []);
if (!iframeWindow) { return <></>; }
return <LayoutWrap {...props} {...options} targetComp={Comp} subWin={iframeWindow} onMount={onGridMount} />; }; }, rightPanel: { visual: false, }, },};
export default [ReactGridLayoutMeta];关键配置解析
isContainer: true
- 标记为容器组件,允许放置子元素
advanceCustom.wrapComponent
- 包装原始组件,注入设计器相关的逻辑
- 获取 iframe 窗口引用(设计器运行在 iframe 中)
- 监听 GridStack 的拖拽事件,与设计器选择状态同步
advanceCustom.rightPanel.visual: false
- 隐藏可视化面板,因为布局组件不需要可视化编辑
advanceCustom.autoGetDom: false
- 禁用自动获取 DOM,因为我们需要手动控制 DOM 引用
2. GridLayout 组件实现
import React, { useCallback, useRef } from 'react';import { ColumnOptions, GridStack } from 'gridstack';import 'gridstack/dist/gridstack.min.css';import 'gridstack/dist/gridstack-extra.min.css';import './layout.scss';import { useEffect, useMemo, useState } from 'react';import { EnginContext } from '@chamn/engine';import { getDefaultContextValue, GridContext, GridContextType } from './context';import { breakpoints } from './config';import { debounce } from 'lodash-es';import { ResponsivePoint } from './type';
export type ReactGridLayoutPropsType = { children: any; onMount?: (grid: GridStack) => void; ctx: EnginContext; subWin?: Window; staticGrid?: boolean; animate?: boolean; layout?: ColumnOptions; $SET_DOM?: (dom: HTMLElement) => void; breakpoints?: ResponsivePoint[]; onBreakpointChange?: (breakpoint: { w: number; label: string }) => void;};
export const GridLayout = ({ subWin, staticGrid, animate, onMount, $SET_DOM, ...props }: ReactGridLayoutPropsType) => { const [ctx, setCtx] = useState<GridContextType>(getDefaultContextValue()); const id = useMemo(() => { return Math.random().toString(32).slice(3, 9); }, []); const gridRef = useRef<GridStack>(); const refDom = useRef<HTMLDivElement>(null);
const init = useCallback(async () => { const tempGridStack: typeof GridStack = (subWin as any)?.GridStack || GridStack;
const grid = tempGridStack.init( { cellHeight: '30px', margin: 3, column: 24, float: true, minRow: 3, animate: true, staticGrid: staticGrid ?? true, columnOpts: { layout: 'scale', }, draggable: { handle: '.grid-drag-handler', }, }, id ); if (!grid) { return; } gridRef.current = grid; onMount?.(grid); setCtx((oldVal) => { return { ...oldVal, gridStack: grid, ready: true, }; });
ctx.onMount?.forEach((el) => { el(grid); }); }, [ctx.onMount, id, onMount, staticGrid, subWin]);
/** 配合设计器使用 */ if (refDom.current) { $SET_DOM?.(refDom.current); }
useEffect(() => { if (props.breakpoints) { gridRef.current?.destroy(false); init(); } }, [init, props.breakpoints, props.layout]);
const responseJudge = debounce(() => { const sunWinW = (subWin ?? window).innerWidth; const pointInfo = breakpoints.find((el) => el.w >= sunWinW);
if (!pointInfo) { return; }
setCtx((oldVal) => { props.onBreakpointChange?.(pointInfo); if (pointInfo.w === ctx.currentBreakpoint.w) { return oldVal; } return { ...oldVal, currentBreakpoint: pointInfo, }; }); }, 50);
useEffect(() => { window.addEventListener('resize', responseJudge); subWin?.addEventListener('resize', responseJudge); responseJudge(); return () => { window.removeEventListener('resize', responseJudge); subWin?.removeEventListener('resize', responseJudge); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []);
const finalCtx = useMemo(() => { return { ...ctx, breakpoints: props.breakpoints ?? breakpoints, }; }, [ctx, props.breakpoints]);
return ( <GridContext.Provider value={finalCtx}> <div id={id} className="grid-stack" ref={refDom}> {props.children} </div> </GridContext.Provider> );};核心功能
- GridStack 初始化:使用 GridStack 库创建网格布局
- 响应式断点:监听窗口大小变化,切换不同的布局断点
- Context 提供:通过 React Context 向子组件提供 GridStack 实例和当前断点信息
3. LayoutWrap 包装组件
import { useRef } from 'react';import { GridLayout, ReactGridLayoutPropsType } from '..';import { GridStack, GridStackElementHandler } from 'gridstack';import { breakpoints } from '../config';import { GridItemPropsType } from '../GridItem';
type ChangeLayoutEvent = { detail: { el: HTMLElement; grid: GridStack; x: number; y: number; w: number; h: number; }[];};
export const LayoutWrap = ( props: ReactGridLayoutPropsType & { targetComp: typeof GridLayout; }) => { const { targetComp: Comp, ...restProps } = props; const ref = useRef<GridStack>();
const initEditLogic = (grid: GridStack) => { const updateGridItemLayout: GridStackElementHandler = (changeLayout) => { const { detail }: ChangeLayoutEvent = changeLayout as any; const sunWinW = props.subWin!.innerWidth; const pointInfo = breakpoints.find((el) => el.w >= sunWinW); detail.forEach((item) => { const nodeId = item.el.getAttribute('data-grid-id'); const node = props.ctx.engine.pageModel.getNode(String(nodeId)); if (node) { const plainProps: GridItemPropsType = node.getPlainProps(); const newResponsive = plainProps.responsive; let targetItem = newResponsive.find((el) => el.label === pointInfo!.label); if (!targetItem) { targetItem = { label: pointInfo!.label, info: {} as any, }; newResponsive.push(targetItem); } targetItem.info = { x: item.x, y: item.y, w: item.w, h: item.h, };
node.updateValue({ props: plainProps, }); } }); }; grid.on('change', updateGridItemLayout); };
return ( <Comp {...restProps} animate={true} staticGrid={false} subWin={props.subWin} onMount={(grid) => { ref.current = grid; initEditLogic(grid); restProps.onMount?.(grid); }} /> );};设计器集成
LayoutWrap 是连接组件和设计器的桥梁:
- 监听布局变化:当用户拖拽 GridItem 时,GridStack 会触发
change事件 - 同步到页面模型:将布局变化同步到 Chameleon 的页面模型中
- 响应式支持:根据当前窗口宽度确定断点,更新对应断点的布局信息
4. GridItem 物料定义
/* eslint-disable react-hooks/rules-of-hooks */import { CMaterialType } from '@chamn/model';import { snippetsGridItem } from './snippets';import { useEffect, useState } from 'react';import { DesignerPluginInstance } from '@chamn/engine/dist/plugins/Designer/type';import { breakpoints } from './config';import { DesignerCtx } from '@chamn/engine/dist/plugins/Designer/components/Canvas';import { debounce } from 'lodash-es';import { GridItemPropsType } from './GridItem';
const GRID_ITEM_INSTANCE_MAP: any = {};
export const ReactGridItemMeta: CMaterialType = { componentName: 'GridItem', title: '高级布局容器', category: '高级布局', groupName: '内置组件', props: [ { name: 'responsive', title: 'Responsive', setters: [ { componentName: 'ArraySetter', initialValue: breakpoints, props: { collapse: { open: true, }, item: { initialValue: { w: 0, label: 'customSize' }, setters: [ { componentName: 'ShapeSetter', initialValue: { with: 0, c: 0, }, props: { collapse: { open: true, }, elements: [ { name: 'label', title: 'label', setters: [ { componentName: 'StringSetter', props: { disabled: true, }, }, ], valueType: 'number', }, { name: 'info', title: 'info', valueType: 'object', setters: [ { componentName: 'ShapeSetter', initialValue: { with: 0, label: '', }, props: { collapse: false, elements: [ { name: 'w', title: 'width', setters: ['NumberSetter', 'ExpressionSetter'], valueType: 'number', }, { name: 'h', title: 'height', setters: ['NumberSetter', 'ExpressionSetter'], valueType: 'number', }, { name: 'x', title: 'offsetX', setters: ['NumberSetter', 'ExpressionSetter'], valueType: 'number', }, { name: 'y', title: 'offsetY', setters: ['NumberSetter', 'ExpressionSetter'], valueType: 'number', }, ], }, }, ], }, ], }, }, ], }, }, }, ], valueType: 'number', }, ], isContainer: true, npm: { name: 'GridItem', package: __PACKAGE_NAME__ || '', version: __PACKAGE_VERSION__, destructuring: true, exportName: 'GridItem', }, disableEditorDragDom: true, advanceCustom: { rightPanel: { advanceOptions: { render: false, loop: false, }, }, autoGetDom: false, toolbarViewRender: ({ node, context, toolBarItemList }) => { // 引擎自带的 显示隐藏,与编辑模式冲突,这里隐藏,不允许隐藏 toolBarItemList.splice(1, 1); const [posInfo, setPostInfo] = useState({ label: '', w: 0, h: 0, x: 0, y: 0, });
const getNodePosAndSizeInfo = debounce(async () => { const compInsRef = GRID_ITEM_INSTANCE_MAP[node.id]; if (!compInsRef) { return; } const posInfo = compInsRef.current?.getCurrentPosAndSizeInfo(); setPostInfo({ label: posInfo.label, x: posInfo.info?.x, y: posInfo.info?.y, w: posInfo.info?.w, h: posInfo.info?.h, }); }, 100);
const registerResize = async () => { node.onChange(getNodePosAndSizeInfo); const ctx = context as DesignerCtx; const designer = await ctx.pluginManager.get<DesignerPluginInstance>('Designer');
const subWin = designer?.export.getDesignerWindow(); subWin?.addEventListener('resize', getNodePosAndSizeInfo); window?.addEventListener('resize', getNodePosAndSizeInfo); }; const removeListener = async () => { const ctx = context as DesignerCtx; const designer = await ctx.pluginManager.get<DesignerPluginInstance>('Designer');
const subWin = designer?.export.getDesignerWindow(); subWin?.removeEventListener('resize', getNodePosAndSizeInfo); window.removeEventListener('resize', getNodePosAndSizeInfo); }; useEffect(() => { getNodePosAndSizeInfo(); registerResize(); return () => { removeListener(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( <div style={{ display: 'flex', float: 'right', zIndex: 999, pointerEvents: 'all', }} > <div style={{ background: 'white', marginRight: '5px', fontSize: '12px', padding: '0 10px', display: 'flex', alignItems: 'center', justifyContent: 'center', }} > <b style={{ paddingRight: '2px', color: 'red', }} > {posInfo.label} </b> | w: {posInfo.w} | h: {posInfo.h} | x: {posInfo.x} | y: {posInfo.y} </div> {toolBarItemList} </div> ); }, onDragStart: async () => { return false; }, wrapComponent: (Comp, options) => { return (props: any) => { return ( <Comp {...props} {...options} dev={true} onGetRef={(ref: any) => { GRID_ITEM_INSTANCE_MAP[options.node.id] = ref; }} /> ); }; }, canDropNode: async (_node, params) => { const { dropNode } = params; if (!dropNode) { return false; } if (dropNode.value.componentName === 'GridLayout') { return true; }
return false; }, onCopy: async (node) => { const newProps: GridItemPropsType = node.getPlainProps(); const newResponsive = newProps.responsive.map((el) => { return { ...el, x: '', y: '', }; }); newProps.responsive = newResponsive; node.updateValue({ props: newProps, }); return true; }, onNewAdd: async (_node, params) => { const { dropNode } = params; if (!dropNode) { return false; } if (dropNode.value.componentName === 'GridLayout') { return true; }
return false; }, },
snippets: snippetsGridItem,};
export default [ReactGridItemMeta];高级特性解析
disableEditorDragDom: true
- 禁用编辑器默认的 DOM 拖拽,使用 GridStack 自己的拖拽系统
advanceCustom.toolbarViewRender
- 自定义工具栏,显示当前布局信息(宽度、高度、位置)
- 实时监听窗口大小变化,更新显示的布局信息
- 移除默认的显示/隐藏按钮(与 GridStack 拖拽冲突)
advanceCustom.onDragStart
- 返回
false,禁用编辑器默认的拖拽行为
advanceCustom.wrapComponent
- 包装组件,注册组件实例引用
- 用于在工具栏中获取实时的布局信息
advanceCustom.canDropNode
- 只允许 GridItem 被放置在 GridLayout 中
- 确保组件层级关系的正确性
advanceCustom.onCopy
- 复制时重置 x、y 坐标
- 避免复制后的元素重叠
advanceCustom.onNewAdd
- 只允许从 GridLayout 拖入创建新的 GridItem
advanceCustom.rightPanel.advanceOptions
- 隐藏 loop 和 render 选项
- 简化属性面板,只显示必要的配置
常用场景示例
场景 1:自定义拖拽行为
advanceCustom: { onDragStart: async (node) => { // 执行自定义逻辑 console.log('开始拖拽', node.id); return true; // 返回 true 允许拖拽 }, onDragEnd: async (node) => { // 拖拽结束后的清理工作 console.log('拖拽结束', node.id); },}场景 2:自定义工具栏
advanceCustom: { toolbarViewRender: ({ node, toolBarItemList }) => { return ( <div style={{ display: 'flex', alignItems: 'center' }}> <span>自定义信息: {node.id}</span> {toolBarItemList} </div> ); },}场景 3:控制放置权限
advanceCustom: { canDropNode: async (node, params) => { const { dropNode } = params; // 只允许放置在特定类型的容器中 if (dropNode?.value.componentName === 'MyContainer') { return true; } return false; },}场景 4:包装组件注入逻辑
advanceCustom: { wrapComponent: (Comp, options) => { return (props) => { // 注入设计器相关的逻辑 const designerCtx = options.ctx;
return ( <div className="design-mode-wrapper"> <Comp {...props} /> </div> ); }; },}场景 5:自定义右侧面板
advanceCustom: { rightPanel: { visual: false, // 隐藏可视化面板 advance: true, advanceOptions: { loop: false, // 隐藏循环选项 render: true, // 显示渲染选项 }, customTabs: [ { key: 'custom', name: '自定义配置', view: ({ node }) => { return <div>自定义配置面板</div>; }, }, ], },}最佳实践
1. 合理使用 wrapComponent
wrapComponent 是一个强大的功能,但要注意:
- ✅ 适合场景:需要访问设计器上下文、需要监听第三方库事件、需要注入特殊逻辑
- ❌ 避免场景:简单的样式修改(应该用 CSS)、不需要设计器交互的组件
2. 性能优化
- 使用
debounce或throttle处理频繁的事件(如 resize) - 避免在
toolbarViewRender中执行重计算 - 合理使用
useMemo和useCallback优化渲染
3. 错误处理
- 所有异步钩子都应该有错误处理
- 返回
false来阻止操作时,确保用户能理解原因
4. 类型安全
- 使用 TypeScript 确保类型安全
- 为自定义的 props 和 context 定义类型
总结
高级物料开发通过 advanceCustom 配置提供了强大的定制能力,让你可以:
- 🎯 完全控制组件的编辑行为
- 🎨 自定义视图和交互体验
- 🔌 深度集成第三方库(如 GridStack)
- 📱 实现响应式布局和断点管理
ReactGridLayout 是一个很好的参考示例,展示了如何将这些高级特性组合使用,创建一个功能完整的布局组件。