<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>ExtJS 3 页面设计器 - 修复版</title>
<!-- 1. 确保 CSS 和 JS 路径正确 -->
<link rel="stylesheet" type="text/css" href="vendor/extjs/resources/css/ext-all.css" />
<!-- Gray theme. Must come AFTER ext-all.css so it overrides the default
blue one; its image assets live in resources/images/gray. -->
<link rel="stylesheet" type="text/css" href="vendor/extjs/resources/css/xtheme-gray.css" />
<!-- 树形表格(Ext.ux.tree.TreeGrid)的样式。它不在 ext-all.css 里,跟组件
脚本一起从官方 SDK 的 examples/ux/treegrid 原样拷过来(GPLv3)。 -->
<link rel="stylesheet" type="text/css" href="vendor/extjs/ux/treegrid/treegrid.css" />
<!-- CodeMirror 5 (MIT). Its stylesheet must come before app.css so
app.css can override the editor chrome to match the theme. -->
<link rel="stylesheet" type="text/css" href="vendor/codemirror/lib/codemirror.css" />
<!-- Folding gutter styles (addon/fold). Must also precede app.css so the
project's own overrides still win. -->
<link rel="stylesheet" type="text/css" href="vendor/codemirror/addon/fold/foldgutter.css" />
<link rel="stylesheet" type="text/css" href="css/app.css" />
<!-- Adapter + core library. Swap ext-all-debug.js for ext-all.js before
shipping to production, then re-test. -->
<script type="text/javascript" src="vendor/extjs/adapter/ext/ext-base.js"></script>
<script type="text/javascript" src="vendor/extjs/ext-all-debug.js"></script>
<!-- 树形表格组件。ext-all-debug.js 里没有 Ext.ux.tree.TreeGrid(它随官方 SDK 的
examples/ux 分发),所以整包拷进了 vendor/extjs/ux/treegrid。加载顺序照官方
示例:Sorter → ColumnResizer → NodeUI → Loader → Columns → TreeGrid,
最后的 TreeGrid.js 才 Ext.reg('treegrid', ...)。 -->
<script type="text/javascript" src="vendor/extjs/ux/treegrid/TreeGridSorter.js"></script>
<script type="text/javascript" src="vendor/extjs/ux/treegrid/TreeGridColumnResizer.js"></script>
<script type="text/javascript" src="vendor/extjs/ux/treegrid/TreeGridNodeUI.js"></script>
<script type="text/javascript" src="vendor/extjs/ux/treegrid/TreeGridLoader.js"></script>
<script type="text/javascript" src="vendor/extjs/ux/treegrid/TreeGridColumns.js"></script>
<script type="text/javascript" src="vendor/extjs/ux/treegrid/TreeGrid.js"></script>
<!-- 图表组件。Chart.js 4(MIT)负责画,ux/Ext.ux.Chart.js 把它包成一个
Ext 组件(xtype: 'uxchart')。设计器画布和导出的页面用的都是这两个
文件——生成代码里写的就是 xtype: 'uxchart'。 -->
<script type="text/javascript" src="vendor/chartjs/chart.umd.js"></script>
<script type="text/javascript" src="ux/Ext.ux.Chart.js"></script>
<!-- CodeMirror 5 runtime for the 获取代码 window. Its stylesheets were
already linked above, but the library itself was never loaded - until
now nothing in this page displayed code. Addon selection matches
index.html so folding behaves the same in both. -->
<script type="text/javascript" src="vendor/codemirror/lib/codemirror.js"></script>
<script type="text/javascript" src="vendor/codemirror/mode/javascript/javascript.js"></script>
<script type="text/javascript" src="vendor/codemirror/addon/fold/foldcode.js"></script>
<script type="text/javascript" src="vendor/codemirror/addon/fold/brace-fold.js"></script>
<script type="text/javascript" src="vendor/codemirror/addon/fold/foldgutter.js"></script>
<style type="text/css">
body { margin: 0; padding: 0; overflow: hidden; font-family: tahoma, arial, verdana, sans-serif; font-size: 12px; }
.canvas-area { background-color: #fff; position: relative; min-height: 100%; }
/* 设计区 body:滚动容器,同时作为绝对定位子项与辅助线的坐标原点。
顺便在这里铺一层网格辅助线:两层 repeating gradient 各画一个方向的
细线,主格 50px 稍深、次格 10px 极浅,不占 DOM,组件正好浮在格子上
当对齐参考。
background-attachment: local 是关键——画布会滚动,网格要跟着内容
一起滚;默认的 scroll 会让网格钉在视口上,滚下去就和组件错位了。 */
.canvas-area .x-panel-body {
position: relative;
background-image:
linear-gradient(to right, rgba(31, 63, 97, .10) 1px, transparent 1px),
linear-gradient(to bottom, rgba(31, 63, 97, .10) 1px, transparent 1px),
linear-gradient(to right, rgba(31, 63, 97, .045) 1px, transparent 1px),
linear-gradient(to bottom, rgba(31, 63, 97, .045) 1px, transparent 1px);
background-size: 50px 50px, 50px 50px, 10px 10px, 10px 10px;
background-attachment: local;
}
/* 工具条上的「网格」按钮关掉时挂这个类(默认开着) */
.canvas-area.no-grid .x-panel-body { background-image: none; }
.canvas-placeholder { border: 2px dashed #ccc; color: #999; text-align: center; padding: 50px; margin: 10px; background: #fafafa; }
.x-component-selected { outline: 2px solid #157fcc !important; position: relative; z-index: 10; }
/* 上面那条的 position:relative 只是为了让 z-index 生效,但绝对布局的子项
(画布上的组件、分割出来的区域面板)本来就是 position:absolute,两者
特异性一样、后写的那条赢,选中一下组件就被打回文档流了——位置会跟着
画布 body 里在流的兄弟元素(占位提示之类)往下跑。这里按类别把定位收回
去;这一条更具体,z-index 照常生效。 */
.canvas-area .x-abs-layout-item.x-component-selected,
.canvas-area .x-border-panel.x-component-selected { position: absolute; }
/* 标尺 */
.designer-ruler { display: block; background: #f0f0f0; cursor: crosshair; }
/* 设计器自己的浮层(辅助线、缩放把手)一律压在这条线以下。
Ext 的窗口管理器 Ext.WindowMgr 从 9000 起发号,每个窗口拿到的
z-index 是 9001 往上(预览、获取代码都是 Ext.Window)。浮层要是
高过它,就会浮在窗口上面:选中一个组件再开预览,画布上的蓝色
选中框和四个角把手会盖在预览页上。1000 这一档在画布组件之上
(选中框才 10),又在任何 Ext 窗口之下,两头都够用。
辅助线覆盖层:铺满设计区但不拦截鼠标,子元素自行开启事件 */
.designer-overlay { position: absolute; left: 0; top: 0; right: 0; bottom: 0; overflow: hidden; z-index: 1000; pointer-events: none; }
.designer-guide { position: absolute; background: #157fcc; pointer-events: auto; }
.designer-guide-v { width: 1px; cursor: ew-resize; }
.designer-guide-h { height: 1px; cursor: ns-resize; }
.designer-guide:hover { background: #d81b60; }
/* 拖动时的自动吸附提示线。原来是正红 #ff3f3f,在铺了网格的白底上太抢眼,
换成浅红:看得见对到哪条线了,又不盖过组件本身 */
.designer-snap { position: absolute; background: #ffb0b8; display: none; }
.designer-snap-v { width: 1px; }
.designer-snap-h { height: 1px; }
/* 复选框/单选框的外层 wrap 是块级元素,会一路撑满容器;设计器里让它按内容
收缩,选中框和缩放把手才贴合控件本身 */
.canvas-area .x-form-check-wrap { display: inline-block; }
/* 分割条:Ext 默认是透明的 5px 区域,设计器里需要看得见、光标要提示可拖动 */
.canvas-area .x-layout-split { background-color: #cfe0ef; }
.canvas-area .x-layout-split:hover { background-color: #7fb2e0; }
.canvas-area .x-layout-split-west,
.canvas-area .x-layout-split-east { cursor: ew-resize; }
.canvas-area .x-layout-split-north,
.canvas-area .x-layout-split-south { cursor: ns-resize; }
/* 分割区域面板不带标题栏(标题栏白占高度,生成的代码里还会多出「上区域/
左区域」这类假标题),改用虚线框 + 角标把区域范围画出来,否则空区域在
画布上完全看不见、也点不中。
注意这里绝对不能写 position:区域面板的定位靠 x-border-panel 上的
position: absolute,本规则的特异性更高(.canvas-area .designer-region
是 0,2,0,.x-border-panel 只有 0,1,0),一旦写上就会把区域打回普通
文档流,主区域会被顶到第一个区域下面。角标挂在内层 body 上,而
.canvas-area .x-panel-body 本身就是 position: relative。 */
.canvas-area .designer-region { outline: 1px dashed #c8ced4; outline-offset: -1px; }
.canvas-area .designer-region > .x-panel-body::before {
content: attr(data-designer-region);
position: absolute; top: 1px; left: 1px; z-index: 1; padding: 0 4px;
font-size: 10px; line-height: 14px; color: #8a949c;
background: rgba(255, 255, 255, .7); pointer-events: none;
}
/* 缩放把手浮层:贴在选中组件上,本身不吃鼠标事件(否则点不到底下的组件),
只有把手吃 */
.designer-resize-host { position: absolute; pointer-events: none; z-index: 1100; display: none; }
.designer-resize-host .designer-handle {
position: absolute; pointer-events: auto; width: 7px; height: 7px;
background-color: #2a6fb5; user-select: none;
}
.designer-resize-host .designer-handle-active { background-color: #d81b60; }
.designer-handle-e { right: -4px; top: 50%; margin-top: -4px; cursor: e-resize; }
.designer-handle-w { left: -4px; top: 50%; margin-top: -4px; cursor: w-resize; }
.designer-handle-n { left: 50%; margin-left: -4px; top: -4px; cursor: n-resize; }
.designer-handle-s { left: 50%; margin-left: -4px; bottom: -4px; cursor: s-resize; }
.designer-handle-ne { right: -4px; top: -4px; cursor: ne-resize; }
.designer-handle-nw { left: -4px; top: -4px; cursor: nw-resize; }
.designer-handle-se { right: -4px; bottom: -4px; cursor: se-resize; }
.designer-handle-sw { left: -4px; bottom: -4px; cursor: sw-resize; }
/* 组件库:按分组铺开的小卡片(原来是树,200px 宽里还要折叠、缩进、长名字
换行,找一个组件要点好几下)。每张卡 = 一个图标 + 短名,两列排布,
名字超宽省略号截断,完整名字放在 title 提示里。
颜色全部走分组变量 --palette-accent(见下面的分组规则):图标、左侧色条、
标题圆点都用它,一眼扫过去就能分出一大类,加一组只要多写一行变量。
图标是内联 SVG:不用图片、不依赖图标字体,颜色取的就是同一个变量 */
.component-palette .x-panel-body { padding: 8px 8px 0; }
.palette-group { margin-bottom: 12px; }
.palette-group-title {
display: flex; align-items: center; gap: 5px;
font-size: 11px; font-weight: bold; color: #7c8794; letter-spacing: .3px;
padding-bottom: 5px; margin-bottom: 7px; border-bottom: 1px solid #e9edf1;
}
/* 标题前的分组色小圆点 */
.palette-group-title::before {
content: ''; flex: 0 0 6px; width: 6px; height: 6px; border-radius: 50%;
background: var(--palette-accent, #c3ccd4);
}
/* 两列等宽卡片 */
.palette-tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.palette-tile {
display: flex; align-items: center; gap: 5px; box-sizing: border-box;
min-height: 32px; padding: 5px 6px;
border: 1px solid #dde3e9; border-left: 3px solid var(--palette-accent, #dde3e9);
border-radius: 4px;
background: #fff; color: #46535f; font-size: 12px;
cursor: move; user-select: none; -webkit-user-select: none;
transition: border-color .12s, box-shadow .12s, color .12s;
}
.palette-tile:hover {
border-color: var(--palette-accent, #9dc3e6);
box-shadow: 0 2px 5px rgba(31, 63, 97, .14);
color: var(--palette-accent, #157fcc);
}
.palette-tile:active { background: #f7fafd; }
/* 抓起时原位淡掉,鼠标上跟着的是 DragZone 的代理块 */
.palette-tile-dragging { opacity: .4; }
.palette-label { flex: 1 1 auto; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.palette-icon {
flex: 0 0 16px; width: 16px; height: 16px;
fill: none; stroke: var(--palette-accent, #7c8794); stroke-width: 1.25;
stroke-linecap: round; stroke-linejoin: round;
/* 图标不吃鼠标事件:事件源要是 SVG 子节点,Ext 的
Ext.get(target).addClass() 会拿到 SVGElement(它的 className 是个
对象而不是字符串),拖拽代理那块就废了 */
pointer-events: none;
}
/* 图标里需要实心的部分(饼图切块、单选框圆点…)、粗线(柱状图的柱子)
和虚线(绝对定位的外框) */
.palette-icon .pi-fill { fill: var(--palette-accent, #7c8794); stroke: none; }
.palette-icon .pi-thick { stroke-width: 2.4; }
.palette-icon .pi-dash { stroke-dasharray: 2 1.8; }
/* 每个分组的强调色。加分组时这里补一行,卡片/图标/圆点自动跟着变 */
.palette-group[data-group="layout"] { --palette-accent: #5b9bd5; }
.palette-group[data-group="container"] { --palette-accent: #70ad47; }
.palette-group[data-group="table"] { --palette-accent: #17a2b8; }
.palette-group[data-group="chart"] { --palette-accent: #d94f8b; }
.palette-group[data-group="form"] { --palette-accent: #ed7d31; }
.palette-group[data-group="basic"] { --palette-accent: #8a6fd4; }
/* 预览 iframe。窗口是 fit 布局,Ext 会给它量好尺寸,这里再兜一层 100%:
万一没量到,iframe 会退回默认的 150px 高,只能看见页面顶上一条 */
.designer-preview-frame { display: block; width: 100%; height: 100%; border: 0 none; background: #fff; }
</style>
<script type="text/javascript">
// 2. 确保所有代码在 Ext 加载完成后执行
Ext.onReady(function() {
// --- 0. 设计器运行时状态 ---
// 必须在下方 Viewport 渲染之前声明:Viewport 一渲染就会触发画布 render
// 回调并写入这些变量,若在此之后再声明,初始化赋值会把它们覆盖掉。
var SNAP = 6; // 吸附阈值(像素)
var canvasBody = null; // 设计区 body(滚动容器)
var overlay = null; // 辅助线覆盖层
var snapV = null, snapH = null; // 吸附提示线
var guides = []; // 手动辅助线:{ vertical, pos, el },pos 为画布坐标
// --- 1. 组件库定义 ---
// 组件库清单。xtype 必须能在 Ext.ComponentMgr 里查到(Ext 3.4 自带的那批),
// 生成代码时也是靠它还原组件的。
// text 是完整名称(鼠标悬停提示用),short 是区块上的短标签:组件库只有
// 200px 宽,用完整名称会被截成省略号,看不出是什么
var componentTemplates = [
{ xtype: 'layout-absolute', text: '绝对定位容器', short: '绝对定位', group: 'layout' },
{ xtype: 'layout-anchor', text: '相对定位容器', short: '相对定位', group: 'layout' },
{ xtype: 'panel', text: '面板 (Panel)', short: '面板', group: 'container' },
{ xtype: 'grid', text: '表格 (Grid)', short: '表格', group: 'table' },
{ xtype: 'advgrid', text: '高级表格 (Advanced Grid)', short: '高级表格', group: 'table' },
{ xtype: 'treegrid', text: '树形表格 (TreeGrid)', short: '树形表格', group: 'table' },
{ xtype: 'pivotgrid', text: '交叉表 (PivotGrid)', short: '交叉表', group: 'table' },
// 图表四个卡片是伪 xtype(像上面的 layout-absolute 一样):画布上
// 真建出来的是同一个 Ext.ux.Chart(xtype: 'uxchart'),靠 chartType
// 分饼图/曲线图/柱状图/散点图。这里只当组件库的一个 key 用
{ xtype: 'chart-pie', text: '饼图 (Pie)', short: '饼图', group: 'chart' },
{ xtype: 'chart-line', text: '曲线图 (Line)', short: '曲线图', group: 'chart' },
{ xtype: 'chart-bar', text: '柱状图 (Bar)', short: '柱状图', group: 'chart' },
{ xtype: 'chart-scatter', text: '散点图 (Scatter)', short: '散点图', group: 'chart' },
{ xtype: 'form', text: '表单 (Form)', short: '表单', group: 'container' },
{ xtype: 'fieldset', text: '字段集 (FieldSet)', short: '字段集', group: 'container' },
{ xtype: 'tabpanel', text: '页签面板 (TabPanel)', short: '页签', group: 'container' },
{ xtype: 'textfield', text: '文本框', short: '文本框', group: 'form' },
{ xtype: 'textarea', text: '多行文本', short: '多行文本', group: 'form' },
{ xtype: 'numberfield', text: '数字框', short: '数字框', group: 'form' },
{ xtype: 'datefield', text: '日期框', short: '日期框', group: 'form' },
{ xtype: 'timefield', text: '时间框', short: '时间框', group: 'form' },
{ xtype: 'combo', text: '下拉框', short: '下拉框', group: 'form' },
{ xtype: 'radio', text: '单选框', short: '单选框', group: 'form' },
{ xtype: 'radiogroup', text: '单选框组', short: '单选框组', group: 'form' },
{ xtype: 'checkbox', text: '复选框', short: '复选框', group: 'form' },
{ xtype: 'checkboxgroup', text: '复选框组', short: '复选框组', group: 'form' },
{ xtype: 'displayfield', text: '展示字段', short: '展示字段', group: 'form' },
{ xtype: 'sliderfield', text: '滑块', short: '滑块', group: 'form' },
{ xtype: 'trigger', text: '触发字段', short: '触发字段', group: 'form' },
{ xtype: 'label', text: '文本标签', short: '文本标签', group: 'form' },
{ xtype: 'htmleditor', text: '富文本', short: '富文本', group: 'form' },
{ xtype: 'button', text: '按钮', short: '按钮', group: 'basic' }
];
// 组件卡片上的图标:16×16 内联 SVG,key 就是上面的 xtype。
// 这里只画图形,颜色/线宽/实心/虚线全部交给 CSS(见 .palette-icon),
// 所以加一个组件只要补一行路径,不用碰样式。viewBox 是 0 0 16 16,
// 所以坐标都按 16 见方来写,四周留 1.5 的空当。
// 没写图标的组件卡片会退化成纯文字(buildPaletteHtml 里判断)
var PALETTE_ICONS = {
// 布局:虚线框 + 一个块;虚线框 + 左右拉开的条
'layout-absolute': '<rect class="pi-dash" x="1.5" y="2.5" width="13" height="11" rx="1.5"/><rect class="pi-fill" x="4.4" y="5.4" width="4.6" height="3.6" rx=".6"/>',
'layout-anchor': '<rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><path d="M3.6 8h8.8M3.6 8l1.8-1.5M3.6 8l1.8 1.5M12.4 8l-1.8-1.5M12.4 8l-1.8 1.5"/>',
// 容器
'panel': '<rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><path d="M1.5 6.2h13"/>',
'form': '<rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><path d="M3.6 6.6h2.8M8.4 6.6h4M3.6 10h2.8M8.4 10h4"/>',
'fieldset': '<path d="M5.8 4.6H3.5A1.5 1.5 0 0 0 2 6.1v5.4a1.5 1.5 0 0 0 1.5 1.5h9a1.5 1.5 0 0 0 1.5-1.5V6.1a1.5 1.5 0 0 0-1.5-1.5h-2.3"/><path d="M4.4 2.5h4.4"/>',
'tabpanel': '<path d="M1.5 6v6.5h13V6"/><path d="M1.5 6h3.8V3.4h3.6V6h5.6"/>',
// 表格:田字格、田字格 + 左边的树杈、田字格 + 左上角实心块
'grid': '<rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><path d="M1.5 6.2h13M1.5 9.8h13M5.8 2.5v11M10.2 2.5v11"/>',
// 高级表格:田字格 + 顶上多一条工具条,条里放个放大镜
'advgrid': '<rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><path d="M1.5 6.2h13M1.5 9.8h13M5.8 6.2v7.3M10.2 6.2v7.3"/><circle cx="12.3" cy="4.3" r="1.1"/><path d="M13.2 5.2l.7.7"/>',
'treegrid': '<rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><path d="M1.5 6.2h13M5.6 2.5v11"/><path d="M3.1 8.4h1.5M3.1 11.2h1.5"/>',
'pivotgrid': '<rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><path d="M1.5 7h13M7 2.5v11"/><path class="pi-fill" d="M3.4 2.5h3.6v4.5H2.5V3.4a.9.9 0 0 1 .9-.9z"/>',
// 图表:坐标轴 + 各自的图形
'chart-pie': '<circle cx="8" cy="8" r="5.8"/><path class="pi-fill" d="M8 8V2.2a5.8 5.8 0 0 1 5 8.6z"/>',
'chart-line': '<path d="M2.5 2.2v11.3h11.3"/><path d="M4.6 10.6l2.8-3.3 2.4 1.8 3-4.1"/>',
'chart-bar': '<path d="M2.5 2.2v11.3h11.3"/><path class="pi-thick" d="M5.6 13.5V9.4M8.8 13.5V6.4M12 13.5V8.4"/>',
'chart-scatter': '<path d="M2.5 2.2v11.3h11.3"/><circle class="pi-fill" cx="5.7" cy="10.2" r="1.2"/><circle class="pi-fill" cx="8.6" cy="6.4" r="1.2"/><circle class="pi-fill" cx="11.4" cy="9.4" r="1.2"/>',
// 表单
'textfield': '<rect x="1.5" y="5" width="13" height="6.5" rx="1.5"/><path d="M4.3 6.8v2.9"/>',
'textarea': '<rect x="1.5" y="2.8" width="13" height="10.4" rx="1.5"/><path d="M4.2 6h7.6M4.2 8.2h7.6M4.2 10.4h4.6"/>',
'numberfield': '<rect x="1.5" y="5" width="13" height="6.5" rx="1.5"/><path d="M10.6 5v6.5"/><path d="M11.8 7.4l.9-.9.9.9M11.8 9.4l.9.9.9-.9"/>',
'datefield': '<rect x="1.5" y="3.4" width="13" height="10.2" rx="1.5"/><path d="M1.5 6.8h13M4.9 2.4v2.6M11.1 2.4v2.6"/><path class="pi-fill" d="M4.9 8.6h1.6v1.6H4.9zM8 8.6h1.6v1.6H8z"/>',
'timefield': '<circle cx="8" cy="8" r="5.7"/><path d="M8 4.6V8l2.4 1.7"/>',
'combo': '<rect x="1.5" y="5" width="13" height="6.5" rx="1.5"/><path d="M9.9 5v6.5"/><path d="M10.9 7.6l1 1 1-1"/>',
'radio': '<circle cx="8" cy="8" r="5.6"/><circle class="pi-fill" cx="8" cy="8" r="2.1"/>',
'radiogroup': '<circle cx="3.6" cy="5.6" r="2.1"/><circle cx="3.6" cy="10.8" r="2.1"/><path d="M7.4 5.6h6.4M7.4 10.8h6.4"/>',
'checkbox': '<rect x="2.4" y="2.4" width="11.2" height="11.2" rx="2"/><path d="M5.4 8.1l1.8 1.9 3.5-4"/>',
'checkboxgroup': '<rect x="1.5" y="3.3" width="4.4" height="4.4" rx="1.2"/><rect x="1.5" y="8.4" width="4.4" height="4.4" rx="1.2"/><path d="M7.6 5.5h6.4M7.6 10.6h6.4"/>',
'displayfield': '<path d="M2 8.2h8.4"/><path class="pi-dash" d="M2 11.4h12"/>',
'sliderfield': '<path d="M1.5 8h13"/><circle cx="6" cy="8" r="2.4"/>',
'trigger': '<rect x="1.5" y="5" width="13" height="6.5" rx="1.5"/><path d="M9.6 5v6.5"/><path d="M10.7 8h2"/>',
'label': '<path d="M2.4 4.6h11.2M2.4 8h11.2M2.4 11.4h5.6"/>',
'htmleditor': '<rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><path d="M5.6 5.3h2.1a1.6 1.6 0 0 1 0 3.2H5.6zM5.6 8.5h2.4a1.7 1.7 0 0 1 0 3.4H5.6z"/>',
// 基础
'button': '<rect x="2" y="5.4" width="12" height="5.6" rx="2.6"/><path d="M5.6 8.2h4.8"/>'
};
// 容器型 xtype:拖到表单上时不往里塞(表单只收字段)
// grid/treegrid/pivotgrid 都是 Panel 派生的,但里面是表体,装不下别的组件
var CONTAINER_XTYPES = ['panel', 'form', 'grid', 'treegrid', 'pivotgrid',
'tabpanel', 'fieldset', 'layout-absolute', 'layout-anchor'];
// 能配「内边距 / 组件间距」的容器
function isSpacingContainer(cmp) {
var t = cmp.getXType();
return t === 'panel' || t === 'form' || t === 'fieldset';
}
// 可投放的容器:布局容器、区域面板、表单、字段集、页签面板
function isDropContainer(cmp) {
var t = cmp.getXType();
return t === 'panel' || t === 'form' || t === 'fieldset' || t === 'tabpanel' ||
!!cmp._designerLayout || !!cmp._isDesignerRegion;
}
// 右侧属性面板可选布局方式(Ext 3.4 已注册到 Ext.Container.LAYOUTS 的 key)
var LAYOUT_TYPES = ['auto', 'absolute', 'anchor', 'form', 'fit', 'column', 'border', 'accordion', 'card', 'hbox', 'vbox'];
var LAYOUT_DATA = [];
Ext.each(LAYOUT_TYPES, function(t) { LAYOUT_DATA.push([t]); });
// 图表的属性行:属性表里显示中文名,回写时换回 Ext.ux.Chart 认的类型名
// (生成代码里写的也是英文名)。定义在这里是因为右边的属性面板
// (下面第 4 节那个 PropertyGrid)建的时候就要用 CHART_TYPE_DATA
var CHART_TYPE_NAMES = { pie: '饼图', line: '曲线图', bar: '柱状图', scatter: '散点图' };
var CHART_TYPE_KEYS = { '饼图': 'pie', '曲线图': 'line', '柱状图': 'bar', '散点图': 'scatter' };
var CHART_TYPE_DATA = [['饼图'], ['曲线图'], ['柱状图'], ['散点图']];
// ================= 属性行规格表 =================
// 「不同的组件,右侧显示不同的属性」这句话就落在这张表上:一条 = 一个
// 属性,四件事写在一起 ——
// when(cmp) 哪些组件有这一行(不写就是人人都有)
// get(cmp) 现在是什么值,显示在属性表里
// set(cmp, v) 改完怎么写回画布上的那个组件(外观要当场变)
// code(item) 生成代码时落成什么属性(不生成就返回 null)
// cssOf/cssTarget 这一项其实是样式时,落成外层 style 还是 bodyStyle
// 属性表的行、属性回写、代码生成三处都遍历这张表,加一个属性只改这里
// 一处,不用三个地方各维护一份。
//
// kind 决定用哪种编辑器:text 文本框 / number 数字 / color 颜色 /
// bool 显示-隐藏(下拉:属性表里布尔值只会渲染成 true/false,必须
// 换成下拉) / enum 下拉,选项写在 values 里 / chartData 图表数据那种
// 加宽右对齐的文本框(见下面自定义编辑器)。
//
// 面板的外观(字号、圆角、边框、标题栏)不在这里逐项写读和写:值统一
// 存在组件的 _xxx 上,由 applyPanelStyle 一次性刷到 DOM,这里只负责把
// 值读出来显示、把代码拼出来。
var SHOW_HIDE = [['显示'], ['隐藏']];
// 有独立外观盒子的容器:面板 / 表单 / 字段集 / 表格 / 页签 …… 都派生自
// Ext.Panel。图表(Ext.ux.Chart)继承的是 BoxComponent,样式另走一套
function isStyleContainer(cmp) { return cmp instanceof Ext.Panel; }
// 文本框一家(含带扳机的下拉、日期时间),它们才有占位提示
function isTextFieldLike(cmp) {
var t = cmp.getXType();
return t === 'textfield' || t === 'textarea' || t === 'numberfield' ||
t === 'datefield' || t === 'timefield' || t === 'trigger' || t === 'combo';
}
function isNumberLike(cmp) {
var t = cmp.getXType();
return t === 'numberfield' || t === 'sliderfield';
}
function isDateLike(cmp) {
var t = cmp.getXType();
return t === 'datefield' || t === 'timefield';
}
function isChart(cmp) { return cmp.getXType() === 'uxchart'; }
// 能取值/填值的字段。开关类的值走 Checked、展示字段走 Text,
// 都不在这里重复给
function canSetValue(cmp) {
var t = cmp.getXType();
return !!cmp.isFormField && t !== 'checkbox' && t !== 'radio' &&
t !== 'displayfield' && t !== 'hidden' && t !== 'htmleditor' &&
!!cmp.setValue;
}
// 数字框的「不限制」哨兵(Ext.form.NumberField 的默认上下限)。判定一个
// 值算不算真的取值范围:得是有限数、且不是这两个哨兵之一。定义必须在
// PROP_SPECS 之前——表里的 get/code 是在选取组件时跑的,那时这些 var
// 得已经赋过值(函数声明会提升,var 的赋值不会)
var NO_MIN = Number.NEGATIVE_INFINITY;
var NO_MAX = Number.MAX_VALUE;
function hasLimit(v, sentinel) {
return typeof v === 'number' && isFinite(v) && v !== sentinel;
}
// ================= 高级表格 =================
// 「高级表格」不是新的组件类型,就是一个普通 GridPanel(xtype: 'grid')
// 多带一条工具条:搜索条件(按列生成的输入框 + 查询/重置),外加一份可选
// 的「列请求地址」。组件上带三个设计器标记:_advGrid / _searchCols(用哪
// 几列当搜索条件)/ _columnUrl(列定义去哪取),属性行、工具条、生成代码
// 三处共用这几个标记。
function isAdvGrid(cmp) { return !!(cmp && cmp._advGrid); }
// 列配置。渲染之后以 colModel 为准(「加载列」换过列的就是它),
// 还没渲染时看配置里的 columns
function advGridColumns(cmp) {
if (!cmp) return [];
return (cmp.colModel && cmp.colModel.config) ? cmp.colModel.config : (cmp.columns || []);
}
function advColumnByIndex(columns, dataIndex) {
var found = null;
Ext.each(columns || [], function(c) {
if (!found && c.dataIndex === dataIndex) found = c;
});
return found;
}
// 真正生效的搜索条件列。名单里写错的列名一律丢掉——工具条上不该出现
// 一个表格里根本没有的搜索框
function advGridSearchCols(cmp) {
var columns = advGridColumns(cmp), out = [];
Ext.each((cmp && cmp._searchCols) || [], function(di) {
if (di && advColumnByIndex(columns, di) && out.indexOf(di) === -1) out.push(di);
});
return out;
}
// 「搜索条件」填的是列名(dataIndex),逗号/分号/空格分隔
function parseSearchCols(text, cmp) {
var columns = advGridColumns(cmp), out = [];
Ext.each(String(text == null ? '' : text).split(/[,,;;\s]+/), function(name) {
name = Ext.util.Format.trim(name);
if (name && advColumnByIndex(columns, name) && out.indexOf(name) === -1) out.push(name);
});
return out;
}
// 搜索框的类型按这一列第一行数据的类型来定:数字给数字框、日期给日期框,
// 其余给文本框。列本身不带类型信息(示例表是内存数据),看值最省事
function advSearchFieldType(store, dataIndex) {
var rec = (store && store.getAt) ? store.getAt(0) : null;
var v = rec ? rec.data[dataIndex] : null;
if (typeof v === 'number') return 'numberfield';
if (Ext.isDate(v)) return 'datefield';
return 'textfield';
}
// 「查询」:把工具条上带 name 的输入框收成参数,交给 store 重新加载。
// 下面这两个 deliberately 写成一整行、也不调任何外部函数 —— 生成代码里直接用
// String() 取它们的源码内联进去(见 advGridTbarCode),设计器和导出页共用
// 同一份实现,不会各写一遍走样。
// 两处讲究:
// ① 用 items.each 而不是 findBy —— Ext 3 的 Container.findBy 要谓词严格返回
// true 才算数,返回「有名字」这种真值会被丢掉;
// ② reload 前先看有没有 proxy —— 画布上的示例数据是 ArrayStore,
// Store.reload 会一路走到 this.proxy.request,没有 proxy 直接抛异常。
// 参数照样落到 baseParams 上(真实后端 store 就是靠它取搜索条件的),
// 换成带 proxy 的 store 就会真的重新加载
function advQueryHandler(btn) { var g = btn.findParentByType('grid'), p = {}; btn.ownerCt.items.each(function(f) { if (f.isFormField && f.name) p[f.name] = f.getValue(); }); var s = g.getStore(); s.baseParams = p; if (s.proxy) s.reload(); }
function advResetHandler(btn) { btn.ownerCt.items.each(function(f) { if (f.isFormField) f.reset(); }); }
// 工具条的内容。设计器建工具条、改完搜索条件重建、生成代码,都走这里
function advGridTbarItems(cmp) {
var columns = advGridColumns(cmp), items = [];
var store = cmp.getStore ? cmp.getStore() : cmp.store;
Ext.each(advGridSearchCols(cmp), function(di) {
var col = advColumnByIndex(columns, di);
items.push({ xtype: 'tbtext', text: (col && col.header ? col.header : di) + ':' });
items.push({ xtype: advSearchFieldType(store, di), width: 120, name: di });
});
if (items.length) items.push('-');
items.push({ text: '查询', handler: advQueryHandler });
items.push({ text: '重置', handler: advResetHandler });
return items;
}
// 搜索条件改了,工具条上的输入框跟着变。工具条是渲染时就建好的,只能
// 把里面的控件清掉重加(removeAll(true) 会连旧控件一起销毁)
function applyAdvGridToolbar(cmp) {
var tb = cmp.getTopToolbar ? cmp.getTopToolbar() : null;
if (!tb) return;
tb.removeAll(true);
tb.add(advGridTbarItems(cmp));
tb.doLayout();
}
// 右侧「加载列」:请求「列请求地址」,用返回的列配置重设表格的列。
// 约定接口返回 Ext 的列配置数组:[{ header: '姓名', dataIndex: 'name' }, ...]
function loadAdvGridColumns() {
var cmp = currentSelectedCmp;
if (!isAdvGrid(cmp)) { Ext.Msg.alert('提示', '先选中画布上的高级表格'); return; }
var url = cmp._columnUrl;
if (!url) { Ext.Msg.alert('提示', '先在「列请求地址」里填上地址'); return; }
Ext.Ajax.request({
url: url,
success: function(resp) {
var cols;
try { cols = Ext.decode(resp.responseText); } catch (e) { cols = null; }
if (!Ext.isArray(cols) || !cols.length) {
Ext.Msg.alert('提示', '接口返回的不是列配置数组');
return;
}
cmp.reconfigure(cmp.getStore(), new Ext.grid.ColumnModel(cols));
cmp.doLayout();
updatePropertyGrid(cmp);
positionResizeHost(cmp);
},
failure: function() { Ext.Msg.alert('提示', '请求列定义失败:' + url); }
});
}
var PROP_SPECS = [
// ---- 面板/容器:外观 ----
{ name: 'FontSize', kind: 'number', when: isStyleContainer,
get: function(c) { return c._fontSize || 0; },
set: function(c, v) { c._fontSize = toPx(v); applyPanelStyle(c); },
cssTarget: 'body',
cssOf: function(it) { return it._fontSize ? 'font-size: ' + it._fontSize + 'px;' : null; } },
{ name: 'FontColor', kind: 'color', when: isStyleContainer,
get: function(c) { return c._fontColor || ''; },
set: function(c, v) {
var col = normalizeColor(v);
if (col === null) return; // 认不出的颜色忽略,和 BodyColor 一致
c._fontColor = col;
applyPanelStyle(c);
},
cssTarget: 'body',
cssOf: function(it) { return it._fontColor ? 'color: ' + it._fontColor + ';' : null; } },
{ name: 'Radius', kind: 'number', when: isStyleContainer,
get: function(c) { return c._radius || 0; },
set: function(c, v) { c._radius = toPx(v); applyPanelStyle(c); },
cssTarget: 'both',
cssOf: function(it) { return it._radius ? 'border-radius: ' + it._radius + 'px;' : null; } },
{ name: 'Shadow', kind: 'bool', when: isStyleContainer,
get: function(c) { return c._shadow ? '显示' : '隐藏'; },
set: function(c, v) { c._shadow = (String(v) === '显示' || v === true); applyPanelStyle(c); },
cssTarget: 'el',
cssOf: function(it) { return it._shadow ? 'box-shadow: ' + PANEL_SHADOW + ';' : null; } },
{ name: 'BorderWidth', kind: 'number', when: isStyleContainer,
get: function(c) { return c._borderWidth || 0; },
set: function(c, v) { c._borderWidth = toPx(v); applyPanelStyle(c); },
cssTarget: 'el',
cssOf: function(it) {
return it._borderWidth
? 'border: ' + it._borderWidth + 'px solid ' + (it._borderColor || PANEL_BORDER_COLOR) + ';'
: null;
} },
// 边框颜色单独一行没意义(没设粗细就画不出边),它是被上面那条
// 一起拼进 border 里的,所以这里没有自己的 code/cssOf
{ name: 'BorderColor', kind: 'color', when: isStyleContainer,
get: function(c) { return c._borderColor || ''; },
set: function(c, v) {
var col = normalizeColor(v);
if (col === null) return;
c._borderColor = col;
applyPanelStyle(c);
} },
// ---- 面板:标题栏。表头是渲染时按 title 建出来的,没有表头的
// 组件(区域面板、已经关掉表头的)不给这几行 ----
{ name: 'TitleAlign', kind: 'enum', values: [['左'], ['中'], ['右']], when: hasHeader,
get: function(c) {
return c._titleAlign === 'center' ? '中' : (c._titleAlign === 'right' ? '右' : '左');
},
set: function(c, v) {
c._titleAlign = (v === '中') ? 'center' : (v === '右' ? 'right' : 'left');
applyPanelStyle(c);
} },
{ name: 'TitleSize', kind: 'number', when: hasHeader,
get: function(c) { return c._titleSize || 0; },
set: function(c, v) { c._titleSize = toPx(v); applyPanelStyle(c); } },
{ name: 'TitleColor', kind: 'color', when: hasHeader,
get: function(c) { return c._titleColor || ''; },
set: function(c, v) {
var col = normalizeColor(v);
if (col === null) return;
c._titleColor = col;
applyPanelStyle(c);
} },
// ---- 容器:内边距 / 组件间距 ----
{ name: 'Padding', kind: 'number', when: isSpacingContainer, refresh: true,
get: function(c) { return toPx(c._padding); },
set: applyPadding,
cssTarget: 'body',
cssOf: function(it) { return it._padding > 0 ? 'padding: ' + it._padding + 'px;' : null; } },
{ name: 'Spacing', kind: 'number', when: isSpacingContainer, refresh: true,
get: function(c) { return toPx(c._spacing); },
set: applySpacing,
code: function(it) {
// 落到容器的 defaults 上,以后新拖进去的图元也自动带间距
return (it._spacing > 0 && !hasRegionChildren(it))
? "defaults: { style: 'margin: " + it._spacing + "px;' }"
: null;
} },
// ---- 按钮 / 标签 / 展示字段的文案 ----
{ name: 'Text', kind: 'text', when: function(c) {
var t = c.getXType();
return t === 'button' || t === 'label' || t === 'displayfield';
},
get: function(c) {
if (c.getXType() === 'displayfield') return c.getValue ? c.getValue() : (c.value || '');
return c.text || '';
},
set: setComponentText,
code: function(it) {
if (it.getXType() === 'displayfield') {
var v = it.getValue ? it.getValue() : it.value;
return v ? "value: '" + esc(v) + "'" : null;
}
return it.text ? "text: '" + esc(it.text) + "'" : null;
} },
// ---- 表单字段 ----
{ name: 'FieldLabel', kind: 'text', when: function(c) { return !!c.isFormField; },
get: function(c) { return c.fieldLabel || ''; },
set: function(c, v) { if (c.setFieldLabel) c.setFieldLabel(v); c.fieldLabel = v; },
code: function(it) { return it.fieldLabel ? "fieldLabel: '" + esc(it.fieldLabel) + "'" : null; } },
{ name: 'Name', kind: 'text', when: function(c) { return !!c.isFormField; },
get: function(c) { return c.name || ''; },
set: function(c, v) { c.name = v; },
code: function(it) { return it.name ? "name: '" + esc(it.name) + "'" : null; } },
{ name: 'Value', kind: 'text', when: canSetValue,
get: function(c) { return c.getRawValue ? c.getRawValue() : (c.value || ''); },
set: function(c, v) { c.setValue(v); },
code: function(it) {
var v = it.getValue ? it.getValue() : it.value;
return v ? "value: '" + esc(v) + "'" : null;
} },
{ name: 'EmptyText', kind: 'text', when: isTextFieldLike,
get: function(c) { return c.emptyText || ''; },
set: function(c, v) {
c.emptyText = v;
// Ext 只在失焦/重置时补占位文字,改完得自己喊一声
if (c.rendered && c.applyEmptyText) c.applyEmptyText();
},
code: function(it) { return it.emptyText ? "emptyText: '" + esc(it.emptyText) + "'" : null; } },
// 只给能打的输入框:复选框/单选框/滑块的 readOnly 是 Field 继承来的
// 空壳(改的是外层 div 的 dom.readOnly,点了照样变),给了也是白给
{ name: 'ReadOnly', kind: 'enum', values: [['可编辑'], ['只读']],
when: function(c) {
return !!c.setReadOnly && isTextFieldLike(c) && c.getXType() !== 'displayfield';
},
get: function(c) { return c.readOnly ? '只读' : '可编辑'; },
set: function(c, v) { c.setReadOnly(String(v) === '只读'); },
code: function(it) { return it.readOnly ? 'readOnly: true' : null; } },
{ name: 'AllowBlank', kind: 'enum', values: [['允许为空'], ['必填']],
when: function(c) { return c.isFormField && c.allowBlank !== undefined; },
get: function(c) { return c.allowBlank === false ? '必填' : '允许为空'; },
set: function(c, v) { c.allowBlank = (String(v) !== '必填'); },
code: function(it) { return it.allowBlank === false ? 'allowBlank: false' : null; } },
{ name: 'BoxLabel', kind: 'text',
when: function(c) {
var t = c.getXType();
return (t === 'checkbox' || t === 'radio') && !!c.boxLabel;
},
get: function(c) { return c.boxLabel || ''; },
set: setBoxLabel,
code: function(it) { return it.boxLabel ? "boxLabel: '" + esc(it.boxLabel) + "'" : null; } },
{ name: 'Checked', kind: 'enum', values: [['选中'], ['未选中']],
when: function(c) {
var t = c.getXType();
return t === 'checkbox' || t === 'radio';
},
get: function(c) {
return (c.getValue ? c.getValue() : c.checked) ? '选中' : '未选中';
},
set: function(c, v) {
var on = (String(v) === '选中' || v === true);
if (c.setValue) c.setValue(on);
c.checked = on;
},
code: function(it) {
var on = it.getValue ? it.getValue() : it.checked;
return on ? 'checked: true' : null;
} },
// ---- 数字 / 日期时间字段 ----
// 数字框自带 minValue = -Infinity / maxValue = Number.MAX_VALUE,意思
// 是「不限制」。这两个哨兵值都不能当真的取值范围:
// -Infinity 连 Ext.isNumber 都不认(它要求 isFinite),属性表里那一行
// 根本建不出来;Number.MAX_VALUE 会显示成 1.7976931348623157e+308。
// 所以只有「有限的、且不等于哨兵」的值才算设过;没有就是空行、不写进代码。
{ name: 'MinValue', kind: 'number', when: isNumberLike,
get: function(c) { return hasLimit(c.minValue, NO_MIN) ? c.minValue : ''; },
set: function(c, v) {
var n = parseFloat(v);
if (isFinite(n)) {
c.minValue = n;
if (c.setMinValue) c.setMinValue(n); // 滑块会转手给内部 slider
} else {
// 清空 = 交回不限制。滑块内部那套只认数字,不去动它的轨道,
// 只把组件自己记的值清掉(属性表和生成代码读的是这一份)
c.minValue = c.slider ? undefined : NO_MIN;
if (!c.slider && c.setMinValue) c.setMinValue(undefined);
}
syncSlider(c);
},
code: function(it) {
return hasLimit(it.minValue, NO_MIN) ? 'minValue: ' + it.minValue : null;
} },
{ name: 'MaxValue', kind: 'number', when: isNumberLike,
get: function(c) { return hasLimit(c.maxValue, NO_MAX) ? c.maxValue : ''; },
set: function(c, v) {
var n = parseFloat(v);
if (isFinite(n)) {
c.maxValue = n;
if (c.setMaxValue) c.setMaxValue(n);
} else {
c.maxValue = c.slider ? undefined : NO_MAX;
if (!c.slider && c.setMaxValue) c.setMaxValue(undefined);
}
syncSlider(c);
},
code: function(it) {
return hasLimit(it.maxValue, NO_MAX) ? 'maxValue: ' + it.maxValue : null;
} },
{ name: 'Format', kind: 'text', when: isDateLike,
get: function(c) { return c.format || ''; },
set: function(c, v) {
c.format = v;
// format 只在取值/回填时用到,已经填了值的要重新回填一次才看得出来
if (c.getValue && c.setValue) {
var cur = c.getValue();
if (cur) c.setValue(cur);
}
},
code: function(it) { return it.format ? "format: '" + esc(it.format) + "'" : null; } },
// ---- 下拉框 ----
{ name: 'Editable', kind: 'bool',
when: function(c) { return c.getXType() === 'combo'; },
get: function(c) { return c.editable === false ? '隐藏' : '显示'; },
set: function(c, v) {
c.editable = (String(v) === '显示' || v === true);
if (c.rendered && c.setEditable) c.setEditable(c.editable);
},
// 这一项一直是显式写出来的(默认 true 也写),和下拉框本来的
// 代码生成保持一致
code: function(it) { return 'editable: ' + (it.editable !== false); } },
{ name: 'ForceSelection', kind: 'bool',
when: function(c) { return c.getXType() === 'combo'; },
get: function(c) { return c.forceSelection ? '显示' : '隐藏'; },
set: function(c, v) {
c.forceSelection = (String(v) === '显示' || v === true);
if (c.rendered && c.setForceSelection) c.setForceSelection(c.forceSelection);
},
code: function(it) { return it.forceSelection ? 'forceSelection: true' : null; } },
// ---- 绝对定位容器里的坐标。顶层的画布就是绝对定位的,所以画布上
// 的组件都有这两行 ----
{ name: 'X', kind: 'number', when: isAbsoluteChild,
get: function(c) { return c.x || 0; },
set: function(c, v) { c.setPosition(parseInt(v, 10) || 0, c.y || 0); },
code: function(it) {
return isAbsoluteChild(it) ? 'x: ' + (it.x || 0) : null;
} },
{ name: 'Y', kind: 'number', when: isAbsoluteChild,
get: function(c) { return c.y || 0; },
set: function(c, v) { c.setPosition(c.x || 0, parseInt(v, 10) || 0); },
code: function(it) {
return isAbsoluteChild(it) ? 'y: ' + (it.y || 0) : null;
} },
// ---- 表格 ----
{ name: 'StripeRows', kind: 'bool',
when: function(c) {
var t = c.getXType();
return t === 'grid' || t === 'treegrid';
},
get: function(c) { return c.stripeRows ? '显示' : '隐藏'; },
set: function(c, v) {
c.stripeRows = (String(v) === '显示' || v === true);
var view = c.getView ? c.getView() : null;
if (view) {
view.stripeRows = c.stripeRows;
if (view.refresh) view.refresh();
}
},
code: function(it) { return it.stripeRows ? 'stripeRows: true' : null; } },
// ---- 高级表格:搜索条件 + 列请求地址。两行都只存在组件上,
// 落到代码里的是工具条和那个取列的 render 回调(结构性拼装,
// 见 renderItem 的表格分支),所以这里没有 code ----
{ name: 'SearchColumns', kind: 'text', wide: true, when: isAdvGrid,
// refresh:写错的列名会被丢掉,回显归一化之后真正生效的那份
refresh: true,
get: function(c) { return advGridSearchCols(c).join(', '); },
set: function(c, v) {
c._searchCols = parseSearchCols(v, c);
applyAdvGridToolbar(c);
} },
{ name: 'ColumnUrl', kind: 'text', wide: true, when: isAdvGrid, refresh: true,
get: function(c) { return c._columnUrl || ''; },
set: function(c, v) { c._columnUrl = Ext.util.Format.trim(String(v == null ? '' : v)); } },
{ name: 'Aggregator', kind: 'enum',
values: [['sum'], ['avg'], ['count'], ['min'], ['max']],
when: function(c) { return c.getXType() === 'pivotgrid'; },
get: function(c) { return c.aggregator || 'sum'; },
set: function(c, v) { c.setAggregator(v); repivot(c); },
code: function(it) { return "aggregator: '" + esc(it.aggregator || 'sum') + "'"; } },
{ name: 'Measure', kind: 'text',
when: function(c) { return c.getXType() === 'pivotgrid'; },
get: function(c) { return c.measure || ''; },
set: function(c, v) { c.setMeasure(v); repivot(c); },
code: function(it) { return "measure: '" + esc(it.measure) + "'"; } },
// ---- 图表 ----
{ name: 'ChartType', kind: 'enum', values: CHART_TYPE_DATA, when: isChart, refresh: true,
get: function(c) { return CHART_TYPE_NAMES[c.chartType] || c.chartType; },
set: function(c, v) {
if (c.setChartType) c.setChartType(CHART_TYPE_KEYS[v] || v);
else c.chartType = v;
},
code: function(it) { return "chartType: '" + esc(it.chartType || 'bar') + "'"; } },
{ name: 'Legend', kind: 'bool', when: isChart, refresh: true,
get: function(c) { return c.showLegend === false ? '隐藏' : '显示'; },
set: function(c, v) {
var on = (String(v) === '显示' || v === true);
if (c.setLegend) c.setLegend(on); else c.showLegend = on;
},
code: function(it) { return it.showLegend === false ? 'showLegend: false' : null; } },
{ name: 'Data', kind: 'text', wide: true, when: isChart, refresh: true,
get: function(c) { return chartDataText(c.data); },
set: function(c, v) {
if (c.setChartData) c.setChartData(parseChartData(v));
else c.data = parseChartData(v);
},
code: function(it) { return 'data: ' + chartDataLiteral(it.data); } }
];
// 面板阴影和默认边框色。设计器和生成出来的代码用的是同一组值,
// 所以提出来当常量,别一边写一遍
var PANEL_SHADOW = '0 2px 9px rgba(23, 43, 77, .22)';
var PANEL_BORDER_COLOR = '#b7c1cc';
// 数字框的「不限制」哨兵 NO_MIN / NO_MAX 和 hasLimit 定义在属性行规格表
// 前面(表里的 get/code 要用),这里只放滑块自己的同步
// 滑块的可拖范围改了要重新铺一次轨道,不然拖着还是照旧范围走。
// 取值范围被清空时是 undefined(或数字框的哨兵,见上面 hasLimit),
// 别拿它去喂滑块
function syncSlider(cmp) {
if (cmp.getXType() !== 'sliderfield' || !cmp.slider) return;
if (isFinite(cmp.minValue) && cmp.slider.setMinValue) cmp.slider.setMinValue(cmp.minValue);
if (isFinite(cmp.maxValue) && cmp.slider.setMaxValue) cmp.slider.setMaxValue(cmp.maxValue);
if (cmp.slider.update) cmp.slider.update();
}
// 交叉表的聚合方式和度量列一改,整张表要重新摊一遍。view.refresh(true)
// 就是生成代码里 viewready 用的那一下(见 renderItem),确实会重算
function repivot(cmp) {
if (cmp.view && cmp.view.refresh) cmp.view.refresh(true);
}
// 面板外观:值都存在组件自己的 _xxx 上,这里一次性刷到 DOM。
// 六项各写一个 setter 的话,「刷到哪个元素上」这件事要重复六遍,
// 集中在这里只维护一次。
function applyPanelStyle(cmp) {
if (!cmp || !cmp.el || !cmp.el.dom) return;
var el = cmp.el;
var body = (cmp.body && cmp.body.dom) ? cmp.body : el;
var radius = cmp._radius ? cmp._radius + 'px' : '';
// 圆角要连 body 一起圆:不带边框的面板看得见的那块底色就是 body,
// 只圆外层的话四个角会露出直角
el.setStyle('border-radius', radius);
body.setStyle('border-radius', radius);
el.setStyle('box-shadow', cmp._shadow ? PANEL_SHADOW : '');
// 粗细为 0 就是把内联边框撤掉,回到主题自己的边线
el.setStyle('border', cmp._borderWidth
? cmp._borderWidth + 'px solid ' + (cmp._borderColor || PANEL_BORDER_COLOR)
: '');
body.setStyle('font-size', cmp._fontSize ? cmp._fontSize + 'px' : '');
body.setStyle('color', cmp._fontColor || '');
var t = titleTextEl(cmp);
if (t) {
t.setStyle('text-align', cmp._titleAlign || '');
t.setStyle('font-size', cmp._titleSize ? cmp._titleSize + 'px' : '');
t.setStyle('color', cmp._titleColor || '');
}
}
// 表头里的标题文字。Panel.setTitle 走的是 header.child('span'),那个
// span 就是它(headerTextCls = 'x-panel-header-text')
function titleTextEl(cmp) {
if (!cmp.header || !cmp.header.dom) return null;
return cmp.header.child('span.x-panel-header-text') || cmp.header.child('span');
}
// --- 2. 左侧组件库 ---
// 以前是 TreePanel:折叠展开、节点缩进、长文本换行,在 200px 宽里找个
// 组件要点好几下。改成按分组铺开的小区块,一屏看全,直接往画布上拖。
var PALETTE_GROUPS = [
{ key: 'layout', title: '布局容器' },
{ key: 'container', title: '容器组件' },
// 表格、树形表格、交叉表都是 Panel 派生的,但里子是表体,拖放规则
// 和普通容器不一样,单独分一组更好找
{ key: 'table', title: '表格组件' },
{ key: 'chart', title: '图表组件' },
{ key: 'form', title: '表单组件' },
{ key: 'basic', title: '基础组件' }
];
// 卡片是纯 HTML(不是 Ext 组件):这里只是拖拽的起点,不需要各自的
// Ext 组件生命周期,用组件反而要处理布局、选中、事件一堆东西
function buildPaletteHtml() {
var html = ['<div class="palette">'];
PALETTE_GROUPS.forEach(function(g) {
var tiles = componentTemplates.filter(function(t) { return t.group === g.key; });
if (!tiles.length) return;
html.push('<div class="palette-group" data-group="' + g.key + '">');
html.push('<div class="palette-group-title">' + g.title + '</div>');
html.push('<div class="palette-tiles">');
tiles.forEach(function(t) {
var icon = PALETTE_ICONS[t.xtype];
// data-xtype 是拖拽时唯一要带走的东西(见 initPaletteDragZone)。
// 图标是装饰:没有图标的组件退化成纯文字卡片,其他照旧
html.push('<div class="palette-tile" data-xtype="' + t.xtype + '" title="' + t.text + '">');
if (icon) {
html.push('<svg class="palette-icon" viewBox="0 0 16 16" aria-hidden="true">' + icon + '</svg>');
}
html.push('<span class="palette-label">' + (t.short || t.text) + '</span>');
html.push('</div>');
});
html.push('</div>');
html.push('</div>');
});
html.push('</div>');
return html.join('');
}
var componentPalette = new Ext.Panel({
region: 'west',
title: '组件库',
width: 200,
split: true,
collapsible: true,
autoScroll: true,
cls: 'component-palette',
html: buildPaletteHtml(),
listeners: {
'render': function(p) { initPaletteDragZone(p.body.dom); }
}
});
// --- 3. 中间画布(顶部/左侧标尺 + 设计区)---
// 标尺用 BoxComponent + autoEl:canvas,组件自身元素即画布
var rulerH = new Ext.BoxComponent({
region: 'north',
height: 20,
autoEl: { tag: 'canvas', cls: 'designer-ruler' },
listeners: { 'resize': function() { drawRulers(); } }
});
var rulerV = new Ext.BoxComponent({
region: 'west',
width: 20,
autoEl: { tag: 'canvas', cls: 'designer-ruler' },
listeners: { 'resize': function() { drawRulers(); } }
});
var designCanvas = new Ext.Panel({
region: 'center',
id: 'design-canvas',
cls: 'canvas-area',
border: false,
autoScroll: true,
// 中间这块默认就是绝对定位:组件扔在哪就留在哪,位置由 x/y 说了
// 算,不会被布局重排(Ext 的绝对布局认 x/y 配置,渲染时写进
// left/top)。_designerLayout 是设计器自己的标记,拖拽摆放、命中
// 嵌套容器、生成代码要不要写 layout: 'absolute' 都看它。
layout: 'absolute',
_designerLayout: 'absolute',
html: '<div class="canvas-placeholder" id="placeholder-msg">拖拽左侧组件到此处<br/>Start Dragging Here</div>',
listeners: {
'render': function(p) {
initCanvasDropZone(p.body.dom);
initDesignerGuides(p); // 标尺绘制 + 辅助线层
},
'resize': function() { drawRulers(); growCanvas(); }
}
});
var canvasPanel = new Ext.Panel({
region: 'center',
layout: 'border',
border: false,
bodyStyle: 'background-color:#f5f5f5;',
items: [rulerH, rulerV, designCanvas]
});
// --- 4. 右侧属性面板 ---
// 属性表里某个 cell 用什么编辑器:PROP_SPECS 上标了 kind 的按 kind
// 生成,剩下的零头(Layout / Header / Region)就在下面手写。
// 注意布尔值不能直接扔给属性表——它只会渲染成 true/false,得换成
// 显示/隐藏的下拉。
function buildCustomEditors() {
var editors = {};
Ext.each(PROP_SPECS, function(spec) {
if (spec.kind === 'enum' || spec.kind === 'bool') {
var data = (spec.kind === 'bool') ? SHOW_HIDE : spec.values;
editors[spec.name] = new Ext.grid.GridEditor(new Ext.form.ComboBox({
store: new Ext.data.ArrayStore({ fields: ['value'], data: data }),
valueField: 'value',
displayField: 'value',
mode: 'local',
triggerAction: 'all',
editable: false
}));
} else if (spec.wide) {
// 数据那一行最长(「一月:120, 二月:200, …」),属性表的值列
// 才一百多像素,cell 里只能看见开头。这个编辑器单独放宽,
// 并且改成右边缘对齐(tr-tr):编辑框从单元格右边往左铺开,
// 正好盖住整列,不会顶到窗口外面去。
// autoSize 是 Ext.grid.GridEditor 原型上的 "width":不给它
// 关掉,编辑器每次都会被强行拉成单元格那么宽,上面那个
// 宽度白写
editors[spec.name] = new Ext.grid.GridEditor(
new Ext.form.TextField({ width: 232 }),
{ alignment: 'tr-tr', autoSize: false }
);
}
});
return editors;
}
// 属性面板底部的「加载列」:只有选中的是画布上的高级表格时才可用
var loadColsBtn = new Ext.Button({
text: '加载列',
disabled: true,
handler: loadAdvGridColumns
});
var propertyGrid = new Ext.grid.PropertyGrid({
region: 'east',
title: '属性配置',
width: 250,
split: true,
source: {},
bbar: [loadColsBtn],
// 各组件自己的行在 PROP_SPECS 里(连编辑器一起),这里补上表里
// 不出现的几个:布局方式、表头开关、区域名
customEditors: Ext.apply(buildCustomEditors(), {
'Layout': new Ext.grid.GridEditor(
new Ext.form.ComboBox({
store: new Ext.data.ArrayStore({ fields: ['value'], data: LAYOUT_DATA }),
valueField: 'value',
displayField: 'value',
mode: 'local',
triggerAction: 'all',
editable: false
})
),
'Header': new Ext.grid.GridEditor(
new Ext.form.ComboBox({
store: new Ext.data.ArrayStore({ fields: ['value'], data: SHOW_HIDE }),
valueField: 'value',
displayField: 'value',
mode: 'local',
triggerAction: 'all',
editable: false
})
)
}),
listeners: {
'afteredit': function(e) {
updateComponentProperty(e.record.get('name'), e.value);
}
}
});
// --- 5. 主窗口 ---
// 顶部工具条。注意不能把 tbar 挂在 Viewport 上:Viewport 继承的是
// Ext.Container,不认 tbar 这个配置项(只有 Panel 会把它做成工具栏),
// 原来那个 tbar 就一直没渲染出来过。用一块北区面板来装才是能显示的写法。
var actionBar = new Ext.Panel({
region: 'north',
height: 28,
border: false,
bodyStyle: 'display: none;', // 只要工具条,不要面板内容区
tbar: [{
text: '获取代码',
handler: showCodeWindow
}, {
text: '预览',
handler: showPreviewWindow
}, '-', {
// 画布上的网格辅助线开关。默认开着(pressed: true 会带上
// x-btn-pressed),关掉就是在 .canvas-area 上挂个 no-grid
text: '网格',
enableToggle: true,
pressed: true,
toggleHandler: function(btn, on) { setCanvasGrid(on); }
}]
});
new Ext.Viewport({
layout: 'border',
items: [actionBar, componentPalette, canvasPanel, propertyGrid]
});
// ================= 右键菜单 / 分割条 =================
function isLayoutContainer(cmp) {
var t = cmp.getXType();
return t === 'panel' || t === 'form';
}
function regionPanels(cmp) {
var out = [];
if (cmp && cmp.items) {
cmp.items.each(function(c) { if (c._isDesignerRegion) out.push(c); });
}
return out;
}
// 选中 + 右键菜单。最内层组件先响应(stopEvent 阻止冒泡到父容器)
function bindDesignerEvents(cmp) {
if (cmp._designerBound) return;
cmp._designerBound = true;
var bind = function() {
// 用可见盒子绑事件:日期/下拉这类组件的 el 只是里面的输入框,
// 绑在它上面点右边的扳机是不会选中的
var el = designerEl(cmp);
if (!el) return;
el.on('click', function(e) {
e.stopPropagation();
selectComponent(cmp);
});
el.on('contextmenu', function(e) {
e.stopEvent();
showDesignerMenu(cmp, e.getXY());
});
};
// 区域面板是先建后挂载的,创建时还没渲染,得等 render 再绑
if (cmp.rendered) bind();
else cmp.on('render', bind, cmp, { single: true });
}
function showDesignerMenu(cmp, xy) {
var items = [];
// 分割区域本身就是布局容器,所以同样能继续分割(嵌套区域)
if (isLayoutContainer(cmp)) {
items.push({ text: '设置左右分割条', handler: function() { applySplit(cmp, 'horizontal'); } });
items.push({ text: '设置上下分割条', handler: function() { applySplit(cmp, 'vertical'); } });
items.push('-');
items.push({ text: '添加分割区域', menu: { items: [
{ text: '上 (north)', handler: function() { addRegion(cmp, 'north'); } },
{ text: '下 (south)', handler: function() { addRegion(cmp, 'south'); } },
{ text: '左 (west)', handler: function() { addRegion(cmp, 'west'); } },
{ text: '右 (east)', handler: function() { addRegion(cmp, 'east'); } }
] } });
items.push({ text: '清除分割区域', disabled: !regionPanels(cmp).length,
handler: function() { clearSplit(cmp); } });
items.push('-');
}
// 页签面板:增删页签
if (cmp.getXType() === 'tabpanel') {
items.push({ text: '添加页签', handler: function() { addDesignerTab(cmp); } });
items.push({ text: '删除当前页签', disabled: cmp.items.getCount() <= 1,
handler: function() { removeDesignerTab(cmp.getActiveTab()); } });
items.push('-');
}
// 某个页签本身:同样能加/删,位置在它后面
if (isTab(cmp)) {
items.push({ text: '在此页签后添加页签', handler: function() { insertDesignerTabAfter(cmp); } });
items.push({ text: '删除此页签', disabled: cmp.ownerCt.items.getCount() <= 1,
handler: function() { removeDesignerTab(cmp); } });
items.push('-');
}
// 区域面板不能单独删:它由父容器的区域布局撑着,摘掉会让 border 布局缺角,
// 要拆就整个用“清除分割区域”;页签也不能当普通组件删,上面已经给了专门入口
if (!cmp._isDesignerRegion && !isTab(cmp)) {
items.push({ text: '删除组件', handler: function() { removeComponent(cmp); } });
}
new Ext.menu.Menu({ items: items }).showAt(xy);
}
function removeComponent(cmp) {
var host = cmp.ownerCt;
if (!host) return;
// 选中的可能是它自己,也可能是它里面的子组件,两种都要收掉选中态
if (currentSelectedCmp && isDescendantOf(currentSelectedCmp, cmp)) clearSelection();
host.remove(cmp, true);
host.doLayout();
growCanvas(true); // 删掉最下面那个之后画布要缩回去
}
// 切换布局前先销毁旧 layout:BorderLayout 的分割条是它自己创建的 DOM,
// 不销毁就会在切换布局后残留成孤儿元素。
function setDesignerLayout(cmp, type) {
var Cls = Ext.Container.LAYOUTS[type];
if (!Cls) return;
if (cmp.layout && cmp.layout.destroy) cmp.layout.destroy();
cmp.setLayout(new Cls({}));
}
// 区域的显示名。以前是直接当面板标题用的,现在只用于画布上的角标
var REGION_NAMES = { north: '上', south: '下', west: '左', east: '右', center: '主' };
// 区域面板的虚线框 + 角标。加在 DOM 上而不是 config 的 cls 里,
// 生成代码时才不会把设计器自己的类名写进去
function paintRegion(p) {
var el = designerEl(p);
if (!el) return;
el.addClass('designer-region');
// 角标挂到内层 body 上(它是 position: relative),别落到面板元素上,
// 否则 CSS 里就必须写 position 才能定位角标,会把区域打回文档流
var badge = (p.body && p.body.dom) ? p.body.dom : el.dom;
badge.setAttribute('data-designer-region', REGION_NAMES[p.region] || p.region);
}
// 新建区域面板。region/split/minSize 必须写在初始 config 里:
// BorderLayout 是用 item.initialConfig 构造 Region 的(见源码 onLayout)。
// 不给 title:区域只是布局骨架,标题栏白白占掉高度,生成代码里也会多出
// 「上区域/左区域」这类假标题。而 Ext 的表头是渲染时按 title 建的,
// 没有 title 就没有表头、事后也补不出来(Panel.setTitle 只改已有表头),
// 所以区域的可辨识度交给 .designer-region 的虚线框和角标。
function createRegion(pos) {
var cfg = { xtype: 'panel', region: pos, layout: 'auto', _isDesignerRegion: true };
if (pos === 'north' || pos === 'south') cfg.height = 110;
else if (pos === 'west' || pos === 'east') cfg.width = 170;
if (pos !== 'center') { cfg.split = true; cfg.minSize = 30; }
var p = Ext.ComponentMgr.create(cfg);
// 主区域也要能选中、也能再分割,所以统一绑事件
bindDesignerEvents(p);
// 拖分割条改的尺寸要能同步回 config
bindRegionResize(p);
// 建出来还没渲染,designerEl 这时是 null,等挂上去渲染完再画
if (p.rendered) paintRegion(p);
else p.on('render', function() { paintRegion(p); }, p, { single: true });
return p;
}
// 组件在 DOM 里的可见节点不一定是 el:日期/时间/下拉/触发字段、滑块、
// 富文本外面还包着一层 wrap(el 只是里面的 input)。搬 DOM 时必须搬 wrap,
// 否则 input 被拽走、wrap 留在原地塌成 0 高,绑在 wrap 上的点击也点不到了
function moveDomInto(item, target) {
var el = designerEl(item);
if (el && el.dom && target && target.dom) target.dom.appendChild(el.dom);
}
// 把组件搬到新容器。Ext 对“已渲染”的子项不会再走 render 流程,
// DOM 不会自己跟过去,必须手动 append,否则组件仍停在原来的容器里。
function reparent(item, newParent) {
newParent.add(item);
if (item.rendered && newParent.rendered) moveDomInto(item, newParent.body);
newParent.doLayout();
}
// 把容器变成“区域 + 分割条”的 border 布局
function applySplit(cmp, dir) {
if (!isLayoutContainer(cmp)) return;
// 已经是区域布局就先拆干净,否则老区域面板会被当成普通子项搬到主区域里
if (regionPanels(cmp).length) clearSplit(cmp);
var existing = [];
if (cmp.items) cmp.items.each(function(c) { existing.push(c); });
for (var i = 0; i < existing.length; i++) {
if (existing[i].ownerCt) existing[i].ownerCt.remove(existing[i], false);
}
var side = createRegion(dir === 'vertical' ? 'north' : 'west');
var main = createRegion('center');
setDesignerLayout(cmp, 'border');
cmp._designerLayout = 'border';
cmp.add(side);
cmp.add(main);
cmp.doLayout();
for (i = 0; i < existing.length; i++) reparent(existing[i], main);
cmp.doLayout();
selectComponent(cmp);
}
function addRegion(cmp, pos) {
if (!isLayoutContainer(cmp)) return;
if (cmp._designerLayout !== 'border') applySplit(cmp, 'horizontal'); // 先备好 左区域 + 主区域
var exists = false;
cmp.items.each(function(c) { if (c._isDesignerRegion && c.region === pos) exists = true; });
if (exists) return;
cmp.add(createRegion(pos));
setDesignerLayout(cmp, 'border'); // 重建 layout,BorderLayout 才会认出新区域
cmp.doLayout();
selectComponent(cmp);
}
// 区域里可能还套着区域,收集时要一路钻到底,只把真正的组件带出来;
// 否则嵌套的区域面板会被当成普通子项搬到上一层,分割结构就散了。
function collectRegionContents(region, out) {
if (!region.items) return;
region.items.each(function(ch) {
if (ch._isDesignerRegion) collectRegionContents(ch, out);
else out.push(ch);
});
}
// 销毁区域面板之前,先把它的嵌套区域逐层销毁(区域面板的 DOM 是它的子节点,
// 不先处理会留下挂在已销毁组件下的孤儿)
function destroyRegionTree(region) {
if (!region.items) return;
var nested = [];
region.items.each(function(c) { if (c._isDesignerRegion) nested.push(c); });
for (var i = 0; i < nested.length; i++) {
destroyRegionTree(nested[i]);
region.remove(nested[i], true);
}
}
// 拆掉区域面板,把里面的组件提回容器
function clearSplit(cmp) {
var regions = regionPanels(cmp);
if (!regions.length) return;
var moved = [];
for (var i = 0; i < regions.length; i++) collectRegionContents(regions[i], moved);
for (i = 0; i < moved.length; i++) {
if (moved[i].ownerCt) moved[i].ownerCt.remove(moved[i], false);
// 先把 DOM 搬回容器,再销毁区域面板,避免组件被一起销毁
moveDomInto(moved[i], cmp.body);
}
for (i = 0; i < regions.length; i++) destroyRegionTree(regions[i]);
for (i = 0; i < regions.length; i++) cmp.remove(regions[i], true);
setDesignerLayout(cmp, 'auto');
cmp._designerLayout = undefined;
for (i = 0; i < moved.length; i++) {
cmp.add(moved[i]);
clearAbsoluteDragFlag(moved[i]);
}
cmp.doLayout();
selectComponent(cmp);
}
// ================= 页签面板 =================
// 页签就是 TabPanel 里的子面板,加/删都走这里,保证新页签也带设计器事件
function isTab(tab) {
return !!(tab && tab.ownerCt && tab.ownerCt.getXType() === 'tabpanel');
}
function isDescendantOf(cmp, ancestor) {
var p = cmp;
while (p) { if (p === ancestor) return true; p = p.ownerCt; }
return false;
}
function addDesignerTab(tp, title) {
if (!tp || tp.getXType() !== 'tabpanel' || !tp.items) return null;
var tab = Ext.ComponentMgr.create(
newTabConfig(title || ('页签' + (tp.items.getCount() + 1))));
tp.add(tab);
tp.doLayout(); // TabPanel 会在这个阶段给新页签建出标签条
bindDesignerEvents(tab);
if (tp.setActiveTab) tp.setActiveTab(tab); // 新页签直接亮出来,好往里拖东西
return tab;
}
// 注意是 insert(index, item) 而不是 add(index, item):这个版本的
// Container.add 收到多个参数会当成「要加多个组件」,位置参数会被当成组件建出来。
// TabPanel 会在 onAdd 里给新页签补上标签条
function insertDesignerTabAfter(tab) {
if (!isTab(tab)) return null;
var tp = tab.ownerCt;
var newTab = Ext.ComponentMgr.create(newTabConfig('页签' + (tp.items.getCount() + 1)));
tp.insert(tp.items.indexOf(tab) + 1, newTab);
tp.doLayout();
bindDesignerEvents(newTab);
if (tp.setActiveTab) tp.setActiveTab(newTab);
return newTab;
}
function removeDesignerTab(tab) {
if (!isTab(tab)) return;
var tp = tab.ownerCt;
if (tp.items.getCount() <= 1) return; // 至少留一个,页签面板不能空着
// 选中的组件就在这个页签里(或就是它)时,选中态得先清掉,
// 否则属性表还挂在一个已经销毁的组件上
if (currentSelectedCmp && isDescendantOf(currentSelectedCmp, tab)) clearSelection();
tp.remove(tab, true);
tp.doLayout();
selectComponent(tp);
}
function clearSelection() {
var el = designerEl(currentSelectedCmp);
if (el) el.removeClass('x-component-selected');
currentSelectedCmp = null;
clearResizeHandles();
propertyGrid.setSource({});
}
// ================= 画布组件的缩放把手 =================
// 把手放在一个独立浮层里,而不是塞进组件内部:按钮的 el 是 <table>、
// 输入框的 el 就是 <input>,往这两种元素里塞 div 要么报 DOM 异常,
// 要么塞进去了也根本不显示。浮层只镜像组件的位置和尺寸,组件本身不动。
var resizeHost = null, resizeTrackers = [];
// 主区域的大小是 border 布局按剩余空间算出来的,拖了也会被下一次布局覆盖;
// 页签的尺寸由 TabPanel 的卡片布局接管,同理拖不动
function canResize(cmp) {
if (cmp._isDesignerRegion && cmp.region === 'center') return false;
if (isTab(cmp)) return false;
return true;
}
// 有些字段(日期/时间/下拉/触发字段、滑块、富文本)真正的可见盒子是包在
// 外面的 wrap,el 只是个取值用的 input:滑块的是 <input type=hidden>(量出来
// 是 0×0),触发类的只量得到输入框那一截(漏掉右边的扳机)。
// 量尺寸、画选中框、贴把手都要用这个可见盒子。
function designerEl(cmp) {
if (!cmp) return null;
if (cmp.positionEl && cmp.positionEl.dom && cmp.positionEl !== cmp.el) return cmp.positionEl;
var el = cmp.getEl ? cmp.getEl() : null;
return (el && el.dom) ? el : null;
}
function isAbsoluteChild(cmp) {
return !!(cmp.ownerCt && cmp.ownerCt._designerLayout === 'absolute');
}
// 绝对定位的子项能八向缩放(往左/上拖会连带改 x/y);
// 其余组件的坐标由布局决定,尺寸只能往右下长
function resizeHandleDirs(cmp) {
return isAbsoluteChild(cmp) ? ['se', 'e', 's', 'ne', 'nw', 'sw', 'n', 'w'] : ['se', 'e', 's'];
}
function ensureResizeHost() {
if (!resizeHost && canvasBody) {
resizeHost = Ext.get(Ext.DomHelper.append(canvasBody.dom, { cls: 'designer-resize-host' }));
}
return resizeHost;
}
function hideResizeHandles() {
// 显式用 display 而不是 Ext 的 hide():Ext 默认走 visibility: hidden,
// 元素在布局里还占着位置,判断起来容易出岔子
if (resizeHost) resizeHost.setStyle('display', 'none');
}
function clearResizeHandles() {
for (var i = 0; i < resizeTrackers.length; i++) {
if (resizeTrackers[i].destroy) resizeTrackers[i].destroy();
}
resizeTrackers = [];
if (resizeHost) resizeHost.update(''); // 连把手 DOM 一起清掉
}
// 浮层贴着组件。坐标用「画布 body 的内容坐标」而不是页面坐标:
// 这样画布一滚动,把手跟着内容一起走,不用重新算一遍。
function positionResizeHost(cmp) {
var el = designerEl(cmp);
if (!el) { hideResizeHandles(); return; }
var host = ensureResizeHost();
if (!host) return;
var box = el.getBox();
var bb = canvasBody.getBox(), sc = canvasBody.getScroll();
// 直接写样式而不是 setBox:setBox 要靠元素当前的页面坐标换算偏移,
// 浮层隐藏时的坐标是没意义的
host.setStyle({
left: (box.x - bb.x + sc.left) + 'px',
top: (box.y - bb.y + sc.top) + 'px',
width: box.width + 'px',
height: box.height + 'px',
display: 'block'
});
}
// 只有当前选中的组件带把手,选中切换时由 selectComponent 负责装卸
function buildResizeHandles(cmp) {
clearResizeHandles();
if (!designerEl(cmp) || !canResize(cmp)) { hideResizeHandles(); return; }
positionResizeHost(cmp);
var dirs = resizeHandleDirs(cmp);
for (var i = 0; i < dirs.length; i++) addResizeHandle(cmp, dirs[i]);
}
function addResizeHandle(cmp, dir) {
var h = Ext.get(Ext.DomHelper.append(resizeHost.dom, { cls: 'designer-handle designer-handle-' + dir }));
var tracker = new Ext.dd.DragTracker({
el: h,
// onStart 要等超过 5px 容差才触发,其坐标已经不是按下点,
// 基准尺寸/坐标必须在 onBeforeStart 里记录
onBeforeStart: function(e) {
this._pt = e.getXY();
this._box = designerEl(cmp).getBox();
this._pos = { x: cmp.x || 0, y: cmp.y || 0 };
h.addClass('designer-handle-active');
},
onDrag: function(e) {
resizeByHandle(cmp, dir, this, e);
},
onEnd: function(e) {
h.removeClass('designer-handle-active');
positionResizeHost(cmp);
updatePropertyGrid(cmp);
}
});
resizeTrackers.push(tracker);
}
// 拖动中每帧都直接把尺寸写到组件上,属性面板随之实时刷新(不用轮询)
function resizeByHandle(cmp, dir, tracker, e) {
var xy = e.getXY(), b = tracker._box;
var dx = xy[0] - tracker._pt[0], dy = xy[1] - tracker._pt[1];
var MIN_W = 24, MIN_H = 16;
var w = b.width, h = b.height, x = b.x, y = b.y;
if (dir.indexOf('e') !== -1) w = b.width + dx;
if (dir.indexOf('s') !== -1) h = b.height + dy;
if (dir.indexOf('w') !== -1) { w = b.width - dx; x = b.x + dx; }
if (dir.indexOf('n') !== -1) { h = b.height - dy; y = b.y + dy; }
// 顶到最小尺寸时让被拖的那条边停住,另一条边保持不动
if (w < MIN_W) { if (dir.indexOf('w') !== -1) x = b.x + b.width - MIN_W; w = MIN_W; }
if (h < MIN_H) { if (dir.indexOf('n') !== -1) y = b.y + b.height - MIN_H; h = MIN_H; }
w = Math.round(w);
h = Math.round(h);
cmp.setSize(w, h);
// 渲染后 setSize 不再回写 config(尺寸相同还直接返回),这里手动记一份,
// 区域面板生成代码时要用它
cmp.width = w;
cmp.height = h;
if (isAbsoluteChild(cmp)) {
// 拖的是页面坐标,容器内坐标的位移和它一样,直接加到原 x/y 上
cmp.setPosition(Math.max(0, Math.round(tracker._pos.x + (x - b.x))),
Math.max(0, Math.round(tracker._pos.y + (y - b.y))));
}
// 区域面板的尺寸由 border 布局按它自己的尺寸反推,重排后分割条才跟得上
if (cmp.ownerCt && cmp.ownerCt._designerLayout === 'border') cmp.ownerCt.doLayout();
positionResizeHost(cmp);
syncSizeProps(cmp);
}
// 只改宽高两行:拖动中整表重建太浪费,也容易把正在编辑的单元格顶掉。
// getRec 在 PropertyStore(propStore)上,不在内层的 data.Store 上。
function syncSizeProps(cmp) {
if (currentSelectedCmp !== cmp || !propertyGrid.propStore || !cmp.getEl()) return;
var names = ['Width', 'Height'];
var vals = [Math.round(cmp.getWidth()), Math.round(cmp.getHeight())];
for (var i = 0; i < names.length; i++) {
var rec = propertyGrid.propStore.getRec(names[i]);
if (rec) rec.set('value', vals[i]);
}
}
// 区域面板的尺寸是 border 布局按剩余空间算出来的(拖分割条改的也是它),
// 写回 config 后生成代码里的 width/height 才和画布一致
function syncRegionSize(cmp) {
if (!cmp._isDesignerRegion || !cmp.getEl()) return;
if (cmp.region === 'north' || cmp.region === 'south') cmp.height = Math.round(cmp.getHeight());
else if (cmp.region === 'west' || cmp.region === 'east') cmp.width = Math.round(cmp.getWidth());
}
// 分割条是 Ext.SplitBar 在拖,区域尺寸变了不会有回调,给区域面板挂个
// resize 监听:尺寸写回 config,选中的话把手和属性表也跟着走
function bindRegionResize(cmp) {
if (!cmp._isDesignerRegion || cmp._regionResizeBound) return;
cmp._regionResizeBound = true;
cmp.on('resize', function() {
syncRegionSize(cmp);
if (currentSelectedCmp !== cmp) return;
positionResizeHost(cmp);
syncSizeProps(cmp);
});
}
// ================= panel 表头 / 颜色 =================
// Ext 的表头是个 Ext.Element(不是组件),面板没有 title 时压根不建它
function hasHeader(cmp) {
return !!(cmp.header && cmp.header.dom && cmp.header.isVisible());
}
function setPanelHeader(cmp, on) {
if (!cmp.header || !cmp.header.dom) return;
// 面板总高度是「表头 + body」拼出来的,先把当前总高量下来,
// 改完表头再按这个总高重算 body,面板整体才不会跟着长高或变矮
var w = cmp.getWidth(), h = cmp.getHeight();
// 记下这个开关,生成代码时要写成 header: false —— Ext 里表头是
// 渲染时按 title 建的,光有 title 是去不掉表头的
cmp._headerHidden = !on;
// 用 display 而不是 Ext 的 hide()/show():那套默认走 visibility: hidden,
// 表头在布局里还占着高度,body 顶不上来
cmp.header.setStyle('display', on ? '' : 'none');
// 顺带换上 noheader 类,去掉表头下边那条分隔线
if (cmp.body) {
if (on) cmp.body.removeClass('x-panel-body-noheader');
else cmp.body.addClass('x-panel-body-noheader');
}
// 表头显隐不会自动重算 body 高度(setSize 又被 lastSize 缓存挡着),
// 直接走一次 onResize,让 body 吃掉 / 让出这块高度
cmp.onResize(w, h, w, h);
}
// 空串 = 恢复主题默认;非法颜色返回 null(调用方忽略)
function normalizeColor(v) {
var s = String(v === undefined || v === null ? '' : v).replace(/^\s+|\s+$/g, '');
if (!s) return '';
if (/^#?[0-9a-f]{3}$/i.test(s) || /^#?[0-9a-f]{6}$/i.test(s)) {
return s.charAt(0) === '#' ? s : '#' + s;
}
if (/^[a-z]+$/i.test(s) || /^rgba?\(/i.test(s)) return s; // 颜色名 / rgb(...)
return null;
}
function rgbToHex(c) {
var m = /^rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(c || '');
if (!m) return c || '';
var h = function(n) { n = parseInt(n, 10).toString(16); return n.length < 2 ? '0' + n : n; };
return '#' + h(m[1]) + h(m[2]) + h(m[3]);
}
function readColor(el) {
if (!el || !el.dom) return '';
var c = el.getStyle('background-color') || '';
if (!c || c === 'transparent' || /^rgba\(0,\s*0,\s*0,\s*0\)$/.test(c)) return '';
return rgbToHex(c);
}
function setPanelColor(cmp, part, value) {
var el = (part === 'header') ? cmp.header : cmp.body;
if (!el || !el.dom) return;
var color = normalizeColor(value);
if (color === null) return; // 非法颜色,忽略
// 表头默认是渐变图,改底色时要一起把背景图压掉,否则颜色看不出来
el.setStyle({ backgroundColor: color, backgroundImage: color ? 'none' : '' });
}
// 按钮/标签走 setText,展示字段是把值渲染进 div,走 setValue
function setComponentText(cmp, value) {
var t = cmp.getXType();
if (t === 'button' || t === 'label') {
if (cmp.setText) cmp.setText(value);
} else if (t === 'displayfield') {
if (cmp.setValue) cmp.setValue(value);
}
}
// Ext 3.4 的 Checkbox/Radio 没有 setBoxLabel:标签是渲染时一次性插进 wrap
// 的,改文案只能自己去动那个 label 元素
function setBoxLabel(cmp, value) {
cmp.boxLabel = value;
var el = designerEl(cmp);
if (!el) return;
var lbl = el.child('label.x-form-cb-label');
if (!lbl) {
lbl = el.createChild({ tag: 'label', cls: 'x-form-cb-label',
htmlFor: cmp.getEl() ? cmp.getEl().id : '' });
}
lbl.update(value ? Ext.util.Format.htmlEncode(value) : ' ');
}
// ================= 容器间距 =================
// 两个量分开配:Padding 是容器内边距(内容与容器边线的距离),
// Spacing 是子组件之间的外边距。生成代码里分别落到 bodyStyle 和 defaults。
function toPx(v) {
var n = parseInt(v, 10);
return (isNaN(n) || n < 0) ? 0 : n;
}
// 外边距一律加在最外层的可见元素上:字段类的 el 只是内部的 input,
// margin 留在它身上会被外面的 wrap 包进去,凭空多出 20px 空档,
// 量出来的宽高、选中框、缩放把手都会跟着偏(见 designerEl)
function applyItemMargin(cmp, px) {
var el = designerEl(cmp);
if (el) el.setStyle('margin', px + 'px');
}
function normalizeItemMargin(cmp) {
var el = designerEl(cmp), inner = cmp.getEl();
if (!el) return;
if (inner && el !== inner) inner.setStyle('margin', ''); // 让 wrap 去撑外边距
el.setStyle('margin', '10px'); // 与默认配置保持一致
}
function applyPadding(cmp, value) {
var px = toPx(value);
cmp._padding = px;
// 直接写 body 的 padding:换布局、改尺寸都不会把它冲掉(bodyStyle 只在渲染时生效)
if (cmp.body) cmp.body.setStyle('padding', px + 'px');
if (cmp.doLayout) cmp.doLayout();
}
function applySpacing(cmp, value) {
var px = toPx(value);
cmp._spacing = px;
if (!cmp.items) return;
cmp.items.each(function(child) {
// 区域面板的尺寸位置全由 border 布局说了算,给它加外边距只会把分割条挤歪
if (child._isDesignerRegion || !designerEl(child)) return;
applyItemMargin(child, px);
});
if (cmp.doLayout) cmp.doLayout();
}
// --- 核心逻辑实现 ---
// 组件库的拖拽源。整块组件库共用一个 DragZone(不是每个区块挂一个),
// 按下时用 getTarget 反查落在哪个区块上,以后往库里加组件也不用重新绑定。
// 关于组名:Ext 的 DragZone 与 DropZone 靠 ddGroup 配对,这里沿用最早的
// 'TreeDD',只是名字,和树没有关系了。
function initPaletteDragZone(paletteEl) {
// 拖拽以各种方式结束(放到画布上 / 弹回原位 / 中断)都要把区块的淡出
// 恢复,所以三条路径都清一次,removeClass 重入没有副作用
function clearPaletteDragging(data) {
if (data && data.ddel) Ext.get(data.ddel).removeClass('palette-tile-dragging');
}
new Ext.dd.DragZone(paletteEl, {
ddGroup: 'TreeDD',
// 只有从区块上按下才开始拖,组件库空白处按下返回 null 即不拖
getDragData: function(e) {
var tile = e.getTarget('.palette-tile', 5, true);
if (!tile) return null;
var xtype = tile.getAttribute('data-xtype');
if (!xtype) return null;
// ddel 是 DragZone 要做拖拽代理(跟着鼠标的那个块)的源节点,
// 必须是 DOM 节点:DragZone 会对它 cloneNode
return { xtype: xtype, ddel: tile.dom };
},
// 抓起时把原位的区块淡掉,表示它正被拖走
onStartDrag: function() {
Ext.get(this.dragData.ddel).addClass('palette-tile-dragging');
},
onValidDrop: function() {
Ext.dd.DragZone.superclass.onValidDrop.apply(this, arguments);
clearPaletteDragging(this.dragData);
},
afterRepair: function() {
Ext.dd.DragZone.superclass.afterRepair.apply(this, arguments);
clearPaletteDragging(this.dragData);
},
onEndDrag: function(data) { clearPaletteDragging(data); }
});
}
function initCanvasDropZone(canvasEl) {
new Ext.dd.DropZone(canvasEl, {
ddGroup: 'TreeDD', // 必须与组件库的 DragZone 同组,否则收不到拖放
getTargetFromEvent: function(e) {
return e.getTarget('.canvas-area') || e.getTarget('.x-panel-body');
},
onNodeOver: function(target, dd, e, data) {
return Ext.dd.DropZone.prototype.dropAllowed;
},
onNodeDrop: function(target, dd, e, data) {
// 拖拽数据来自组件库 DragZone.getDragData
var xtype = data && data.xtype;
if (!xtype) return false;
// 移除占位符
var placeholder = document.getElementById('placeholder-msg');
if (placeholder) placeholder.parentNode.removeChild(placeholder);
createComponentOnCanvas(xtype, e);
return true;
}
});
}
function createComponentOnCanvas(xtype, dropEvent) {
var canvas = Ext.getCmp('design-canvas');
var config = getDefaultConfig(xtype);
var cmp = Ext.ComponentMgr.create(config);
if (config.layout) cmp._designerLayout = config.layout;
var host = canvas;
if (dropEvent) {
// 用真 xtype 去判断能不能落在表单上:图表卡片是伪 xtype
// (chart-pie 等),真身是 uxchart,拿卡片名判断会认错
host = resolveDropHost(getNestTarget(dropEvent.getXY()), config.xtype || xtype) || canvas;
}
// 绝对定位的宿主里,落点就是坐标:鼠标松在哪就摆在哪。以前是
// 按 (n%3)*120 错开排,落点和实际位置对不上,拖到哪儿全凭猜
if (host._designerLayout === 'absolute' && host.items && dropEvent) {
var hb = host.body.getBox();
var pt = dropEvent.getXY();
cmp.x = Math.max(0, Math.round(pt[0] - hb.x));
cmp.y = Math.max(0, Math.round(pt[1] - hb.y));
}
host.add(cmp);
host.doLayout();
growCanvas();
// 绑定选中 / 右键菜单(组件此时已经渲染,直接绑定即可,不能用 on('render'))
bindDesignerEvents(cmp);
// 默认外边距先落到最外层可见元素上,再按容器的「组件间距」覆盖
normalizeItemMargin(cmp);
if (host._spacing != null) applyItemMargin(cmp, host._spacing);
// 页签面板的子页签是随 config 一起建出来的,得逐个补上事件绑定
if (cmp.getXType() === 'tabpanel' && cmp.items) {
cmp.items.each(function(tab) { bindDesignerEvents(tab); });
}
// 绝对定位容器内的子项支持拖拽自由摆放
if (host._designerLayout === 'absolute') {
enableAbsoluteDrag(cmp, host);
}
selectComponent(cmp);
return cmp;
}
// 设计器一进来画布上就摆着的那个面板。走的是跟拖进来完全同一条路
// (getDefaultConfig → 建组件 → 绑定事件 → 选中),所以它的默认外观、
// 属性行、生成出来的代码跟手动拖进来的面板一模一样,只是落点是定死的:
// 没有拖放事件就没有落点,绝对布局会把它贴在左上角
var DEFAULT_PANEL_X = 20, DEFAULT_PANEL_Y = 20;
function createDefaultCanvasPanel() {
// 画布上有东西了,空占位提示就该收掉(拖放那条路也是这么做的)
var msg = document.getElementById('placeholder-msg');
if (msg && msg.parentNode) msg.parentNode.removeChild(msg);
var cmp = createComponentOnCanvas('panel');
cmp.setPosition(DEFAULT_PANEL_X, DEFAULT_PANEL_Y);
// 挪过位置之后缩放把手要重新贴上去(选中时已经摆过一次了),
// 属性表里的 X/Y 也是选中那一刻读的(那时还没定位),一并回正
positionResizeHost(cmp);
updatePropertyGrid(cmp);
return cmp;
}
// ================= 标尺 / 辅助线 / 自动吸附 =================
// 相关状态变量在文件顶部(第 0 节)声明,见那里的说明。
function initDesignerGuides(panel) {
canvasBody = panel.body;
canvasBody.setStyle('position', 'relative');
panel.el.setStyle('position', 'relative');
overlay = Ext.get(Ext.DomHelper.append(panel.el, { cls: 'designer-overlay' }));
snapV = makeLine('designer-snap designer-snap-v');
snapH = makeLine('designer-snap designer-snap-h');
// 从标尺按住拖出辅助线:顶部标尺出竖线,左侧标尺出横线
rulerH.el.on('mousedown', function(e) {
e.stopEvent();
startLineDrag(addGuide(true, pageToCanvas(e.getPageX(), e.getPageY())[0]), e);
});
rulerV.el.on('mousedown', function(e) {
e.stopEvent();
startLineDrag(addGuide(false, pageToCanvas(e.getPageX(), e.getPageY())[1]), e);
});
canvasBody.on('scroll', function() { drawRulers(); refreshGuides(); });
Ext.EventManager.onWindowResize(function() {
drawRulers();
refreshGuides();
// 布局重排后组件的位置会变,缩放把手要重新贴上去
if (currentSelectedCmp) positionResizeHost(currentSelectedCmp);
});
drawRulers();
growCanvas();
}
// 画布 body 的最小高度。绝对定位的子项不占文档流,body 不会被子项撑
// 高:不给它一个高度,内容全被裁在 0 高的盒子里,滚动条也出不来。
// 这里按最下面那个组件的底边把 body 撑开(底下再留一截空白方便接着
// 拖),并且至少填满可视区。
// shrink=false 时只长不缩——拖动过程中每挪一下就重算,允许缩回去的话
// 拖拽时的钳位范围会跟着变,组件会自己跳。
// 画布内容底下留出来的空白:够接着往下拖,导出页里也不会贴着底边
var CANVAS_PAD = 80;
// 画布上内容的实际范围:最下面那个组件的底边到画布原点的距离。
// 生成代码时算导出页该留多高用的也是它
function canvasExtent() {
var p = Ext.getCmp('design-canvas');
var extent = 0;
if (p && p.items) {
p.items.each(function(c) {
var el = designerEl(c);
if (!el || !el.dom) return;
extent = Math.max(extent, (c.y || 0) + Math.round(el.getHeight()));
});
}
return extent;
}
var canvasMinH = 0;
function growCanvas(shrink) {
var p = Ext.getCmp('design-canvas');
if (!p || !p.body || !p.rendered) return;
var chrome = (p.header && p.header.dom) ? p.header.getHeight() : 0;
var need = Math.max(canvasExtent() + CANVAS_PAD, p.getHeight() - chrome, 200);
if (!shrink) need = Math.max(need, canvasMinH);
canvasMinH = need;
p.body.setStyle('min-height', need + 'px');
}
// 工具条上那个「网格」按钮:网格是画在 .canvas-area .x-panel-body 上的
// 背景图,关掉就是挂个 no-grid 类(见样式表里的注释)
function setCanvasGrid(on) {
var p = Ext.getCmp('design-canvas');
if (!p || !p.el) return;
if (on) p.el.removeClass('no-grid');
else p.el.addClass('no-grid');
}
function makeLine(cls) {
var d = document.createElement('div');
d.className = cls;
overlay.dom.appendChild(d);
return Ext.get(d);
}
// 元素内容区原点(页面坐标)。绝对定位子项以 padding box 为基准,
// 故 withPadding 传 false;普通流内容需要传 true。
function elementOrigin(el, withPadding) {
var d = el.dom;
return [
el.getXY()[0] + el.getBorderWidth('l') + (withPadding ? el.getPadding('l') : 0) - d.scrollLeft,
el.getXY()[1] + el.getBorderWidth('t') + (withPadding ? el.getPadding('t') : 0) - d.scrollTop
];
}
function canvasOrigin() { return elementOrigin(canvasBody, true); }
function overlayOrigin() { return overlay.getXY(); }
function pageToCanvas(pageX, pageY) {
var o = canvasOrigin();
return [pageX - o[0], pageY - o[1]];
}
function canvasToOverlay(x, y) {
var co = canvasOrigin(), oo = overlayOrigin();
return [co[0] + x - oo[0], co[1] + y - oo[1]];
}
function pageToOverlay(pageX, pageY) {
var oo = overlayOrigin();
return [pageX - oo[0], pageY - oo[1]];
}
// ---------- 手动辅助线 ----------
function addGuide(vertical, pos) {
var g = {
vertical: vertical,
pos: pos,
el: makeLine(vertical ? 'designer-guide designer-guide-v' : 'designer-guide designer-guide-h')
};
g.el.on('mousedown', function(e) { e.stopEvent(); startLineDrag(g, e); });
g.el.on('dblclick', function(e) { e.stopEvent(); removeGuide(g); }); // 双击删除
guides.push(g);
placeGuide(g);
return g;
}
function placeGuide(g) {
var p = canvasToOverlay(g.vertical ? g.pos : 0, g.vertical ? 0 : g.pos);
if (g.vertical) g.el.setStyle({ left: p[0] + 'px', top: '0px', height: '100%' });
else g.el.setStyle({ top: p[1] + 'px', left: '0px', width: '100%' });
}
function removeGuide(g) {
g.el.remove();
for (var i = 0; i < guides.length; i++) {
if (guides[i] === g) { guides.splice(i, 1); break; }
}
}
function refreshGuides() {
for (var i = 0; i < guides.length; i++) placeGuide(guides[i]);
hideSnapLines();
}
function startLineDrag(g, e) {
var move = function(ev) {
var c = pageToCanvas(ev.getPageX(), ev.getPageY());
g.pos = g.vertical ? c[0] : c[1];
placeGuide(g);
};
var up = function(ev) {
Ext.getDoc().un('mousemove', move);
Ext.getDoc().un('mouseup', up);
var c = pageToCanvas(ev.getPageX(), ev.getPageY());
if ((g.vertical ? c[0] : c[1]) < -1) removeGuide(g); // 拖回标尺区域即删除
};
Ext.getDoc().on('mousemove', move);
Ext.getDoc().on('mouseup', up);
move(e);
}
// ---------- 吸附提示线 ----------
function hideSnapLines() {
if (snapV) snapV.setStyle('display', 'none');
if (snapH) snapH.setStyle('display', 'none');
}
function showSnapLines(res) {
if (!snapV) return;
if (res.vx !== null) {
var p = pageToOverlay(res.vx, 0);
snapV.setStyle({ left: p[0] + 'px', top: '0px', height: '100%', display: 'block' });
} else snapV.setStyle('display', 'none');
if (res.hy !== null) {
var q = pageToOverlay(0, res.hy);
snapH.setStyle({ top: q[1] + 'px', left: '0px', width: '100%', display: 'block' });
} else snapH.setStyle('display', 'none');
}
// 计算吸附偏移:把被拖组件左/中/右、上/中/下与兄弟组件、容器边界、
// 手动辅助线做比对,返回 { dx, dy, vx, hy }(vx/hy 为页面坐标的对齐线)
function computeSnap(cmp, container, x, y) {
var origin = elementOrigin(container.body, false),
w = cmp.getWidth(), h = cmp.getHeight(),
left = origin[0] + x, top = origin[1] + y,
myX = [left, left + w / 2, left + w],
myY = [top, top + h / 2, top + h],
candX = [], candY = [],
i, a, b, d, best;
container.items.each(function(c) {
if (c === cmp || !c.getEl() || !c.getEl().dom) return;
var box = c.getEl().getBox();
candX.push(box.x, box.x + box.width / 2, box.x + box.width);
candY.push(box.y, box.y + box.height / 2, box.y + box.height);
});
var cw = container.body.getWidth(), ch = container.body.getHeight();
candX.push(origin[0], origin[0] + cw / 2, origin[0] + cw);
candY.push(origin[1], origin[1] + ch / 2, origin[1] + ch);
var co = canvasOrigin();
for (i = 0; i < guides.length; i++) {
if (guides[i].vertical) candX.push(co[0] + guides[i].pos);
else candY.push(co[1] + guides[i].pos);
}
var res = { dx: 0, dy: 0, vx: null, hy: null };
best = SNAP + 1;
for (a = 0; a < myX.length; a++) {
for (b = 0; b < candX.length; b++) {
d = candX[b] - myX[a];
if (Math.abs(d) <= SNAP && Math.abs(d) < best) { best = Math.abs(d); res.dx = d; res.vx = candX[b]; }
}
}
best = SNAP + 1;
for (a = 0; a < myY.length; a++) {
for (b = 0; b < candY.length; b++) {
d = candY[b] - myY[a];
if (Math.abs(d) <= SNAP && Math.abs(d) < best) { best = Math.abs(d); res.dy = d; res.hy = candY[b]; }
}
}
return res;
}
// ---------- 标尺绘制 ----------
function drawRulers() {
if (!canvasBody) return;
drawRuler(rulerH, true);
drawRuler(rulerV, false);
}
function drawRuler(bc, horizontal) {
if (!bc || !bc.rendered || !bc.el || !bc.el.dom) return;
var cv = bc.el.dom,
w = bc.el.getWidth(), h = bc.el.getHeight(),
len = horizontal ? w : h,
thick = horizontal ? h : w;
if (!len || !thick) return;
// 画布缓冲区必须与元素 CSS 尺寸一致,否则绘制会被拉伸
if (cv.width !== w) cv.width = w;
if (cv.height !== h) cv.height = h;
var ctx = cv.getContext('2d');
ctx.clearRect(0, 0, cv.width, cv.height);
ctx.fillStyle = '#f0f0f0';
ctx.fillRect(0, 0, cv.width, cv.height);
// 标尺 0 刻度相对画布内容原点的像素偏移(两者左/上边缘对齐时为 0)
var co = canvasOrigin(), ro = bc.el.getXY();
var off = horizontal ? (co[0] - ro[0]) : (co[1] - ro[1]);
var scroll = horizontal ? canvasBody.dom.scrollLeft : canvasBody.dom.scrollTop;
var start = Math.floor(scroll / 10) * 10, end = scroll + len - off;
ctx.strokeStyle = '#b5b5b5';
ctx.beginPath();
for (var v = start; v <= end; v += 10) {
var p = Math.round(v - scroll + off) + 0.5;
if (p < 0 || p > len) continue;
var major = (v % 50 === 0);
if (horizontal) { ctx.moveTo(p, major ? thick - 10 : thick - 4); ctx.lineTo(p, thick); }
else { ctx.moveTo(major ? thick - 10 : thick - 4, p); ctx.lineTo(thick, p); }
}
ctx.stroke();
ctx.fillStyle = '#555';
ctx.font = '9px tahoma, arial, sans-serif';
for (var v2 = start; v2 <= end; v2 += 50) {
if (v2 < 0) continue;
var p2 = Math.round(v2 - scroll + off) + 0.5;
if (p2 < 0 || p2 > len) continue;
if (horizontal) {
ctx.fillText(v2, p2 + 2, thick / 2 - 1);
} else {
ctx.save();
ctx.translate(thick / 2 - 1, p2 + 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText(v2, 0, 0);
ctx.restore();
}
}
}
function enableAbsoluteDrag(cmp, container) {
if (cmp._absDragEnabled) return; // 幂等:多次切换布局不会重复挂 DragTracker
clearAbsoluteDragFlag(cmp); // 若之前有残留实例,先销毁再重建
cmp._absDragEnabled = true;
cmp._absTracker = new Ext.dd.DragTracker({
el: cmp.getEl(),
// onStart 要等超过 tolerance 才触发,其事件坐标已不是按下点,
// 因此基准坐标必须在 onBeforeStart(真正的 mousedown)里记录,
// 否则组件会滞后一个 tolerance 的偏移量。
onBeforeStart: function(e) {
this._startXY = e.getXY();
this._startPos = [cmp.x || 0, cmp.y || 0];
},
onDrag: function(e) {
var xy = e.getXY();
var x = this._startPos[0] + (xy[0] - this._startXY[0]);
var y = this._startPos[1] + (xy[1] - this._startXY[1]);
// 限制在容器 body 内,避免拖出可视范围
var body = container.body;
x = Math.max(0, Math.min(x, body.getWidth() - cmp.getWidth()));
y = Math.max(0, Math.min(y, body.getHeight() - cmp.getHeight()));
// 自动吸附:对齐兄弟组件 / 容器边界 / 手动辅助线
var snap = computeSnap(cmp, container, x, y);
x = Math.max(0, Math.min(x + snap.dx, body.getWidth() - cmp.getWidth()));
y = Math.max(0, Math.min(y + snap.dy, body.getHeight() - cmp.getHeight()));
cmp.setPosition(x, y);
// 拖到画布下半截时 body 要跟着长高,否则钳位够不着(见 growCanvas)
growCanvas();
// 选中的组件被拖走时,缩放把手要跟着走
if (currentSelectedCmp === cmp) positionResizeHost(cmp);
showSnapLines(snap);
},
onEnd: function(e) {
hideSnapLines();
selectComponent(cmp);
growCanvas(true);
}
});
}
// 组件离开绝对定位容器后要把拖拽器一起撤掉,
// 否则它仍会响应鼠标并往 setPosition 写坐标。
function clearAbsoluteDragFlag(cmp) {
if (cmp._absTracker) {
if (cmp._absTracker.destroy) cmp._absTracker.destroy();
cmp._absTracker = null;
}
cmp._absDragEnabled = false;
}
function getNestTarget(xy) {
var canvas = Ext.getCmp('design-canvas');
var targets = canvas.findBy(function(c) {
// 分割区域本身也是可投放目标(它的 body 比外层容器小,优先生效)
return isDropContainer(c);
});
var best = null, bestArea = Infinity;
for (var i = 0; i < targets.length; i++) {
var t = targets[i];
// 分割区域用整块面板参与命中,这样落在它的标题栏上也算落在区域里;
// 页签面板同理:标题栏(标签条)在 header 里,不在 body 里
var box = ((t._isDesignerRegion || t.getXType() === 'tabpanel') && t.getEl())
? t.getEl().getBox()
: (t.body ? t.body.getBox() : null);
if (!box) continue;
if (xy[0] >= box.x && xy[0] <= box.x + box.width &&
xy[1] >= box.y && xy[1] <= box.y + box.height) {
var area = box.width * box.height;
if (area < bestArea) { best = targets[i]; bestArea = area; }
}
}
return best;
}
// 命中到的容器不一定能直接接住这个组件,这里做一次归属换算:
// 返回 null 表示「不放进去」,调用方会退回到画布根上
function resolveDropHost(target, xtype) {
if (!target) return null;
// 页签面板本身不存组件(没有标题的卡片建不出页签),一律投给当前页签
if (target.getXType() === 'tabpanel') {
var at = target.getActiveTab ? target.getActiveTab() : null;
return at || null;
}
// 落在 border 容器本体上(标题栏、分割条等)时改投主区域:
// 没有 region 的子项 BorderLayout 不会给它定位,会叠在左上角。
if (target._designerLayout === 'border' && !target._isDesignerRegion) {
var centers = target.findBy(function(c) { return c._isDesignerRegion && c.region === 'center'; });
return centers.length ? centers[0] : null;
}
// 表单只收字段/按钮,容器类组件不往里塞
if (target.getXType() === 'form' && CONTAINER_XTYPES.indexOf(xtype) !== -1) return null;
return target;
}
// 字段名要唯一,重复 name 的表单字段提交时后面的会盖掉前面的
var cfgSeq = 0;
function fieldName(prefix) { return prefix + '_' + (++cfgSeq); }
// 下拉框的选项,画布和生成代码共用同一份
var COMBO_DATA = [['1', '选项一'], ['2', '选项二'], ['3', '选项三']];
// ---- 表格 / 树形表格 / 交叉表的示例数据 ----
// 表格和交叉表没数据就是一片空白,先给一份内存数据,拖进画布就有东西看。
// 这些数组只被读(Reader 读行时会另建记录对象),共用同一份没有副作用;
// 而列的配置会被 ColumnModel / TreeGrid.initColumns 改写,所以下面凡是
// 列都用工厂函数现造一份。
var GRID_FIELDS = ['id', 'name', 'dept', 'amount'];
var GRID_DATA = [
['001', '张三', '研发部', 12800],
['002', '李四', '市场部', 9600],
['003', '王五', '财务部', 14300],
['004', '赵六', '研发部', 11200]
];
function gridColumns() {
return [
{ header: '编号', dataIndex: 'id', width: 70 },
{ header: '姓名', dataIndex: 'name', width: 100 },
{ header: '部门', dataIndex: 'dept', width: 100 },
{ header: '金额', dataIndex: 'amount', width: 90, align: 'right' }
];
}
// 树形表格的示例树。同样写成工厂函数:TreeLoader.createNode 会往传进去的
// 节点配置上回写 loader 字段,共用同一个对象的话,第二棵树拿到的会是
// 上一棵已经销毁的树的 loader。最深层节点要标 leaf,否则渲染出来的
// 展开图标点下去既没数据也没回调,会一直转
function treeGridRootConfig() {
return {
text: '公司', task: '公司', expanded: true, children: [
{ text: '研发部', task: '研发部', duration: 13, user: '张三', expanded: true, children: [
{ text: '前台页面', task: '前台页面', duration: 5, user: '张三', leaf: true },
{ text: '后台接口', task: '后台接口', duration: 8, user: '李四', leaf: true }
] },
{ text: '市场部', task: '市场部', duration: 6, user: '王五', expanded: true, children: [
{ text: '渠道推广', task: '渠道推广', duration: 6, user: '王五', leaf: true }
] }
]
};
}
function treeGridColumns() {
return [
{ header: '任务', dataIndex: 'task', width: 200 },
{ header: '工时', dataIndex: 'duration', width: 70, align: 'center' },
{ header: '负责人', dataIndex: 'user', width: 90 }
];
}
// 交叉表的示例数据:地区 × 季度 的销售额
var PIVOT_FIELDS = ['region', 'quarter', 'amount'];
var PIVOT_DATA = [
['华东', 'Q1', 120], ['华东', 'Q2', 150], ['华东', 'Q3', 138],
['华北', 'Q1', 100], ['华北', 'Q2', 130], ['华北', 'Q3', 118],
['华南', 'Q1', 90], ['华南', 'Q2', 112], ['华南', 'Q3', 126]
];
// ---- 图表的示例数据 ----
// Ext.ux.Chart 认的数据就是 [{ label, value }],直接给一份内存数据,
// 拖进画布立刻就有东西看。每次都现造一行新数组:属性表里改数据是整段
// 替换(setChartData),共用一份会被改到别人头上。
var CHART_TITLES = { pie: '占比分布', line: '走势', bar: '月度销量', scatter: '分布' };
var CHART_CATEGORIES = ['一月', '二月', '三月', '四月', '五月', '六月'];
var CHART_VALUES = [120, 200, 150, 80, 170, 210];
function chartDemoData() {
var rows = [];
Ext.each(CHART_CATEGORIES, function(lb, i) { rows.push({ label: lb, value: CHART_VALUES[i] }); });
return rows;
}
// 散点图的横轴是数值轴,标签得给数,不然点会全堆在序号上
var CHART_SCATTER_POINTS = [[12, 40], [18, 65], [24, 52], [31, 95], [38, 78], [45, 130]];
function chartScatterData() {
var rows = [];
Ext.each(CHART_SCATTER_POINTS, function(p) { rows.push({ label: String(p[0]), value: p[1] }); });
return rows;
}
// 页签面板的子项:TabPanel 会把子面板的 header 去掉(见 onBeforeAdd),
// 所以每个页签本身就是一块干净的内容区
function newTabConfig(title) {
return { xtype: 'panel', title: title, layout: 'auto', _spacing: 10 };
}
function getDefaultConfig(xtype) {
var base = { style: 'margin:10px;' };
switch(xtype) {
case 'layout-absolute':
return Ext.apply(base, { xtype: 'panel', title: '绝对定位容器', layout: 'absolute', width: 400, height: 300, frame: true, _spacing: 10 });
case 'layout-anchor':
return Ext.apply(base, { xtype: 'panel', title: '相对定位容器', layout: 'anchor', width: 400, height: 300, frame: true, _spacing: 10 });
case 'panel':
// 面板默认不带边框:画布上的面板是页面容器,套一圈边框加
// 渐变底看着像个控件。要边框的话右侧有「边框宽度」那一行,
// 加上去就是一条实心边框(粗细分开了,比 frame 更好控)
return Ext.apply(base, { xtype: 'panel', title: '新面板', width: 300, height: 200, border: false, _spacing: 10 });
case 'grid':
// 表格没有 store / columns 就是一片空白,配一份内存示例数据,
// 拖进来就能看到表头和行
var gridStore = new Ext.data.ArrayStore({
fields: GRID_FIELDS,
data: GRID_DATA
});
return Ext.apply(base, {
xtype: 'grid',
title: '新表格',
width: 420,
height: 220,
store: gridStore,
columns: gridColumns(),
stripeRows: true
});
case 'advgrid':
// 高级表格:普通表格 + 自带工具条(搜索条件、查询/重置)。
// 工具条在配置里就建好(Ext 在 initComponent 里把它实例化),
// 所以这里用一个"假组件"喂 advGridTbarItems —— 它只用到
// columns / getStore / _searchCols 三样东西
var advStore = new Ext.data.ArrayStore({
fields: GRID_FIELDS,
data: GRID_DATA
});
var advColumns = gridColumns();
return Ext.apply(base, {
xtype: 'grid',
title: '新高级表格',
width: 560,
height: 300,
store: advStore,
columns: advColumns,
stripeRows: true,
// _advGrid 是设计器标记:属性行、生成代码、右侧「加载列」
// 都靠它认这个高级表格
_advGrid: true,
_searchCols: ['name'],
_columnUrl: '',
tbar: advGridTbarItems({
columns: advColumns,
getStore: function() { return advStore; },
_searchCols: ['name']
})
});
case 'treegrid':
// 树形表格(Ext.ux.tree.TreeGrid)。数据走节点树而不是 store:
// 根节点带上 children,loader 只负责把嵌套节点建出来
// (preloadChildren),不需要后端接口。
// 根节点必须是节点实例:TreeGrid.setRootNode 会往 attributes
// 上写 uiProvider,传普通对象会报错
return Ext.apply(base, {
xtype: 'treegrid',
title: '新树形表格',
width: 420,
height: 240,
animate: true,
loader: { preloadChildren: true },
root: new Ext.tree.AsyncTreeNode(treeGridRootConfig()),
columns: treeGridColumns()
});
case 'pivotgrid':
// 交叉表(Ext.grid.PivotGrid,ext-all 自带)。行列轴换一个
// dataIndex 就是另一种透视
var pivotStore = new Ext.data.ArrayStore({
fields: PIVOT_FIELDS,
data: PIVOT_DATA
});
return Ext.apply(base, {
xtype: 'pivotgrid',
title: '新交叉表',
width: 460,
height: 240,
store: pivotStore,
aggregator: 'sum',
measure: 'amount',
stripeRows: true,
leftAxis: [{ dataIndex: 'region', width: 90 }],
topAxis: [{ dataIndex: 'quarter' }],
viewConfig: { title: '销售额' },
listeners: {
// 交叉表的行列表头和左上角标题只有 view.refresh(true)
// 会画:view.renderHeaders 是个空实现,正常渲染流程
// 不碰它们(项目里 app/view/SalesPivot.js 也是这么处理的)
viewready: function(g) { g.view.refresh(true); }
}
});
case 'chart-pie':
case 'chart-line':
case 'chart-bar':
case 'chart-scatter':
// 四个卡片共用同一个组件(Ext.ux.Chart,xtype: 'uxchart'),
// chart-* 只是组件库的 key,这里换掉 xtype、把 chartType 定下来。
// 数据集也一次给好:图表没数据就是一块空白
var ctype = xtype.replace('chart-', '');
return Ext.apply(base, {
xtype: 'uxchart',
chartType: ctype,
title: CHART_TITLES[ctype] || '新图表',
showLegend: true,
width: 400,
height: 260,
data: (ctype === 'scatter') ? chartScatterData() : chartDemoData()
});
case 'form':
return Ext.apply(base, { xtype: 'form', title: '新表单', width: 350, height: 200, frame: true, labelWidth: 60, _spacing: 10 });
case 'fieldset':
return Ext.apply(base, { xtype: 'fieldset', title: '字段集', width: 320, height: 160, _spacing: 10 });
case 'tabpanel':
return Ext.apply(base, {
xtype: 'tabpanel', title: '页签面板',
width: 420, height: 260, activeTab: 0, _spacing: 10,
items: [newTabConfig('页签一'), newTabConfig('页签二')]
});
// ---- 表单字段 ----
case 'textfield':
return Ext.apply(base, { xtype: 'textfield', fieldLabel: '标签', name: fieldName('field'), width: 200 });
case 'textarea':
return Ext.apply(base, { xtype: 'textarea', fieldLabel: '多行文本', name: fieldName('textarea'), width: 240, height: 80 });
case 'numberfield':
return Ext.apply(base, { xtype: 'numberfield', fieldLabel: '数字', name: fieldName('number'), width: 160 });
case 'datefield':
return Ext.apply(base, { xtype: 'datefield', fieldLabel: '日期', name: fieldName('date'), width: 160, format: 'Y-m-d', editable: false });
case 'timefield':
return Ext.apply(base, { xtype: 'timefield', fieldLabel: '时间', name: fieldName('time'), width: 160, format: 'H:i', increment: 30, editable: false });
case 'combo':
return Ext.apply(base, {
xtype: 'combo', fieldLabel: '下拉框', name: fieldName('combo'), width: 200,
mode: 'local', editable: false, triggerAction: 'all', forceSelection: true,
valueField: 'value', displayField: 'text',
store: new Ext.data.ArrayStore({ fields: ['value', 'text'], data: COMBO_DATA })
});
case 'checkbox':
return Ext.apply(base, { xtype: 'checkbox', fieldLabel: '复选框', boxLabel: '选项', name: fieldName('check') });
case 'checkboxgroup':
var cbBase = fieldName('cb');
return Ext.apply(base, {
xtype: 'checkboxgroup', fieldLabel: '复选框组', columns: 2, items: [
{ xtype: 'checkbox', boxLabel: '选项一', name: cbBase + '_1' },
{ xtype: 'checkbox', boxLabel: '选项二', name: cbBase + '_2' }
]
});
case 'radio':
return Ext.apply(base, { xtype: 'radio', fieldLabel: '单选框', boxLabel: '选项', name: fieldName('radio') });
case 'radiogroup':
var rbName = fieldName('rb');
return Ext.apply(base, {
xtype: 'radiogroup', fieldLabel: '单选框组', columns: 2, items: [
{ xtype: 'radio', boxLabel: '选项一', name: rbName, inputValue: '1' },
{ xtype: 'radio', boxLabel: '选项二', name: rbName, inputValue: '2' }
]
});
case 'displayfield':
return Ext.apply(base, { xtype: 'displayfield', fieldLabel: '展示字段', value: '展示内容' });
case 'sliderfield':
return Ext.apply(base, { xtype: 'sliderfield', fieldLabel: '滑块', name: fieldName('slider'), width: 200, minValue: 0, maxValue: 100, value: 40 });
case 'trigger':
// 光有 TriggerField 点扳机会报错,得给个空实现
return Ext.apply(base, { xtype: 'trigger', fieldLabel: '触发字段', name: fieldName('trigger'), width: 200, onTriggerClick: Ext.emptyFn });
case 'label':
return Ext.apply(base, { xtype: 'label', text: '文本标签' });
case 'htmleditor':
return Ext.apply(base, { xtype: 'htmleditor', fieldLabel: '富文本', name: fieldName('html'), width: 380, height: 180 });
case 'button':
return Ext.apply(base, { xtype: 'button', text: '按钮' });
default:
return Ext.apply(base, { xtype: xtype });
}
}
var currentSelectedCmp = null;
function selectComponent(cmp) {
var prev = designerEl(currentSelectedCmp);
if (prev) prev.removeClass('x-component-selected');
currentSelectedCmp = cmp;
// 区域面板选中时顺手把尺寸写回 config
if (cmp && cmp._isDesignerRegion) syncRegionSize(cmp);
var el = designerEl(cmp);
if (el) {
el.addClass('x-component-selected');
// 缩放把手只跟着当前选中的组件,切走就被 buildResizeHandles 收掉
buildResizeHandles(cmp);
} else {
clearResizeHandles();
}
updatePropertyGrid(cmp);
}
// 数据在属性表里是一行文本:「一月:120, 二月:200」。整段编辑比给
// 每行数据做子表格省事得多,改完按回车就重画
function chartDataText(rows) {
var out = [];
Ext.each(rows || [], function(r) { out.push(r.label + ':' + r.value); });
return out.join(', ');
}
// 解析上面那串文本。中英文逗号、分号都当分隔符;「标签」或「:值」缺了
// 就跳过这一项;值不是数字按 0 算(宁可显示 0,也别让图表整块画不出来)
function parseChartData(text) {
var rows = [];
Ext.each(String(text == null ? '' : text).split(/[,,;;]/), function(pair) {
pair = Ext.util.Format.trim(pair);
if (!pair) return;
var i = pair.lastIndexOf(':');
var lb = Ext.util.Format.trim(i === -1 ? pair : pair.substring(0, i));
if (!lb) return;
var v = (i === -1) ? 0 : parseFloat(Ext.util.Format.trim(pair.substring(i + 1)));
rows.push({ label: lb, value: isFinite(v) ? v : 0 });
});
return rows;
}
function updatePropertyGrid(cmp) {
// 没选中高级表格时这个按钮按不动,省得点了才弹提示
if (loadColsBtn) loadColsBtn.setDisabled(!isAdvGrid(cmp));
if (!cmp) { propertyGrid.setSource({}); return; }
// 宽高显示实际像素值:布局算出来的尺寸和 config 里的值不一定一致,
// 而且拖动缩放后两者都会变,只有实际值是不会骗人的
var source = {
'XType': cmp.getXType(),
'Width': Math.round(cmp.getWidth()),
'Height': Math.round(cmp.getHeight())
};
// 区域面板有意不带 title,也就没有表头,Panel.setTitle 只会静默失败
// (它只更新已存在的表头),所以标题相关的行一概不给,换成区域名
if (cmp._isDesignerRegion) source['Region'] = REGION_NAMES[cmp.region] || cmp.region;
else source['Title'] = cmp.title || '';
// 表头 / 颜色:没有表头的面板(比如主区域)不显示 Header。
// 注意表头是渲染时就建好的,事后没法凭空造一个,所以只给已有表头的面板开关。
if (cmp instanceof Ext.Panel) {
if (cmp.header && cmp.header.dom) source['Header'] = hasHeader(cmp) ? '显示' : '隐藏';
source['BodyColor'] = readColor(cmp.body);
if (cmp.header && cmp.header.dom) source['HeaderColor'] = readColor(cmp.header);
}
// 其它行全归 PROP_SPECS 管:哪些组件有哪些行、值怎么读,都写在
// 那张表里(同一个 when 也决定着生成代码时写不写)
Ext.each(PROP_SPECS, function(spec) {
if (spec.when && !spec.when(cmp)) return;
source[spec.name] = spec.get(cmp);
});
// 容器组件(面板/表单)可设置布局方式。布局是结构性的(切成 border
// 要搭区域面板),不适合放进「一个属性一个值」的表里
if (cmp.getXType() === 'panel' || cmp.getXType() === 'form') {
source['Layout'] = (cmp.layout && cmp.layout.type) || 'auto';
}
propertyGrid.setSource(source);
}
function updateComponentProperty(name, value) {
if (!currentSelectedCmp) return;
var cmp = currentSelectedCmp;
// 大部分行由 PROP_SPECS 处理:加一个属性只改表里那一处
var spec = null;
Ext.each(PROP_SPECS, function(s) {
if (spec || s.name !== name) return;
if (s.when && !s.when(cmp)) return;
spec = s;
});
if (spec) spec.set(cmp, value);
// 剩下的是结构性的:改布局、开关表头、换底色
else if (name === 'Title' && cmp.setTitle) cmp.setTitle(value);
else if (name === 'Width') {
var w = parseInt(value, 10);
// 顺手记回 config:区域面板生成代码时要用
if (w > 0) { cmp.setWidth(w); cmp.width = w; }
}
else if (name === 'Height') {
var h = parseInt(value, 10);
if (h > 0) { cmp.setHeight(h); cmp.height = h; }
}
else if (name === 'Layout') applyLayout(cmp, value);
else if (name === 'Header') setPanelHeader(cmp, String(value) === '显示' || value === true);
else if (name === 'BodyColor') setPanelColor(cmp, 'body', value);
else if (name === 'HeaderColor') setPanelColor(cmp, 'header', value);
if (cmp.ownerCt) cmp.ownerCt.doLayout();
// 布局可能把尺寸/坐标改掉(区域面板、自动宽度等),把手要重新贴一次,
// 宽高两行也要回正
positionResizeHost(cmp);
syncSizeProps(cmp);
// 这几项改的是外观/数值而不是 config,改完要把表里的值换成真实值
// (比如颜色填了缩写、非法值被忽略、间距填了负数时要回退显示)。
// spec 上标了 refresh 的也是同一类:显示的那串值和存进去的不完全
// 一样(图表数据要重新拼、开关要换成中文)
if (spec ? spec.refresh : false) {
updatePropertyGrid(cmp);
} else if (name === 'Header' || name === 'BodyColor' || name === 'HeaderColor' ||
name === 'Region') { // Region 只读,改回真实值
updatePropertyGrid(cmp);
}
}
function applyLayout(cmp, type) {
type = String(type || 'auto').toLowerCase();
// border 不能直接套:它必须带 region 子项,走分割区域搭建这条路
if (type === 'border') { applySplit(cmp, 'horizontal'); return; }
var LayoutCls = Ext.Container.LAYOUTS[type];
if (!LayoutCls) return; // 未知布局方式,忽略
// 从 border 切到别的布局:先把区域拆掉、组件提回来,否则区域面板
// 会被当成普通子项留在容器里
if (regionPanels(cmp).length) clearSplit(cmp);
setDesignerLayout(cmp, type);
// 记录布局类型,供生成代码 / 嵌套命中 / 绝对拖拽使用(auto 不写回,保持默认)
cmp._designerLayout = (type === 'auto') ? undefined : type;
// 切换到绝对定位时,为已有子项补 x/y 并启用拖拽摆放
if (type === 'absolute' && cmp.items) {
var i = 0;
cmp.items.each(function(child) {
child.x = (i % 3) * 120;
child.y = Math.floor(i / 3) * 70;
enableAbsoluteDrag(child, cmp);
i++;
});
}
cmp.doLayout();
if (currentSelectedCmp === cmp) {
updatePropertyGrid(cmp);
positionResizeHost(cmp); // 换布局后尺寸/坐标都变了,把手重新贴一次
}
}
// ================= 获取代码 / 预览 =================
// 下面拼的是 HTML 文本,但这段代码自己就住在 lay.html 的一个 <script>
// 里:字符串里只要出现完整的结束标签,HTML 解析器会当场把脚本掐断,
// 后面的代码全都变成页面文字。所以标签一律拆开拼。
var TAG_SCRIPT = '<' + 'script';
var TAG_SCRIPT_END = '</' + 'script>';
// 画布上(含任意层级的子组件)有没有用到某个 xtype。树形表格需要额外的
// ux 文件和样式,导出时必须一起带上,否则预览里那棵树根本建不出来
function canvasHasXType(xtype) {
var canvas = Ext.getCmp('design-canvas');
if (!canvas || !canvas.items) return false;
return canvas.findBy(function(c) { return c.getXType() === xtype; }).length > 0;
}
// 画布上有没有可拖动的分割条(导出页要不要带上分割条样式就看它)
function canvasHasSplit() {
var canvas = Ext.getCmp('design-canvas');
if (!canvas || !canvas.items) return false;
return canvas.findBy(function(c) { return c.split === true; }).length > 0;
}
// 树形表格的 ux 脚本,顺序照官方示例,TreeGrid.js 必须最后
var TREE_GRID_UX = ['TreeGridSorter', 'TreeGridColumnResizer', 'TreeGridNodeUI',
'TreeGridLoader', 'TreeGridColumns', 'TreeGrid'];
// Ext 3.4 默认把分割条做成了 5px 的透明抓取带(ext-all.css 里写的是
// .x-layout-split{background-color:transparent}),平时只靠鼠标指针变成
// 左右箭头来提示。导出的页面是给人用的,标出来才拖得着。配色和画布上
// 那条一致。
var SPLIT_BAR_CSS = [
'.x-layout-split { background-color: #cfe0ef; }',
'.x-layout-split:hover { background-color: #7fb2e0; }',
'.x-layout-split-west, .x-layout-split-east { cursor: ew-resize; }',
'.x-layout-split-north, .x-layout-split-south { cursor: ns-resize; }'
].join('\n');
// 「获取代码」和「预览」共用同一套生成逻辑,只差这一个开关。
// 预览是给人看设计效果的静态快照,分割条是设计期用来调区域大小的
// 工具,不该出现在预览里——拖过之后预览里的布局就和设计对不上了,
// 得按「刷新」才回得来。所以预览模式下不给区域面板写 split:
// BorderLayout 见不到 split 就不生成分割条,自然也拖不动,区域宽度
// 就是设计时定的那个值。
// 「获取代码」出来的仍然是标准 Ext 页面,split: true 照写,真页面里
// 可以自由拖动。
var previewMode = false;
// 整页代码。「获取代码」和「预览」共用这一份,免得两处各拼一套、
// 改了一处忘了另一处,最后预览和拿到的代码对不上。
function buildPageCode() {
var canvas = Ext.getCmp('design-canvas');
var items = '';
if (canvas && canvas.items && canvas.items.getCount() > 0) {
// 深度 8 刚好是 16 空格缩进,和下面 items: [ 那一行对齐
items = renderItems(canvas.items, 8).replace(/\n$/, '');
}
// 树形表格不在 ext-all 里,只有当画布上真的有它时才带上它的样式和脚本
var withTreeGrid = canvasHasXType('treegrid');
// 图表同理:Chart.js 和 Ext.ux.Chart 都不是 ext-all 自带的东西
var withChart = canvasHasXType('uxchart');
var head = [];
head.push('<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />');
head.push('<title>设计的页面</title>');
head.push('<link rel="stylesheet" type="text/css" href="vendor/extjs/resources/css/ext-all.css" />');
head.push('<link rel="stylesheet" type="text/css" href="vendor/extjs/resources/css/xtheme-gray.css" />');
if (withTreeGrid) {
head.push('<link rel="stylesheet" type="text/css" href="vendor/extjs/ux/treegrid/treegrid.css" />');
}
head.push(TAG_SCRIPT + ' type="text/javascript" src="vendor/extjs/adapter/ext/ext-base.js">' + TAG_SCRIPT_END);
head.push(TAG_SCRIPT + ' type="text/javascript" src="vendor/extjs/ext-all-debug.js">' + TAG_SCRIPT_END);
if (withTreeGrid) {
Ext.each(TREE_GRID_UX, function(f) {
head.push(TAG_SCRIPT + ' type="text/javascript" src="vendor/extjs/ux/treegrid/' +
f + '.js">' + TAG_SCRIPT_END);
});
}
if (withChart) {
head.push(TAG_SCRIPT + ' type="text/javascript" src="vendor/chartjs/chart.umd.js">' + TAG_SCRIPT_END);
head.push(TAG_SCRIPT + ' type="text/javascript" src="ux/Ext.ux.Chart.js">' + TAG_SCRIPT_END);
}
var css = ['body { margin: 0; padding: 0; }'];
if (!previewMode && canvasHasSplit()) css.push(SPLIT_BAR_CSS);
head.push('<style type="text/css">' + css.join('\n') + '</style>');
return [
'<!DOCTYPE html>',
'<html>',
'<head>'
].concat(head).concat([
'</head>',
'<body>',
TAG_SCRIPT + ' type="text/javascript">',
'Ext.onReady(function() {',
' new Ext.Viewport({',
" layout: 'fit',",
' items: [{',
" xtype: 'panel',",
' border: false,',
// 设计区是绝对布局(组件带着画布上的 x/y),导出的页面得照搬,
// 否则组件会被布局重排成一条。body 的 min-height 给的是内容
// 范围——绝对定位的子项不占文档流,不撑高的话超出首屏的组件
// 既看不见也滚不到(设计器里 growCanvas 算的是同一件事)
" layout: 'absolute',",
' autoScroll: true,',
" bodyStyle: 'position: relative; min-height: " + (canvasExtent() + CANVAS_PAD) + "px;',",
' items: [',
items,
' ]',
' }]',
' });',
'});',
TAG_SCRIPT_END,
'</body>',
'</html>'
]).join('\n');
}
// 预览用的整页 HTML = 上面那份代码 + 一行 <base>。
// iframe 里那个 about:blank 文档没有可用的基地址,代码中的相对路径
// (vendor/extjs/...)会解析到 about:blank 上,脚本和样式全部 404。
// 而生成给用户的代码必须保持相对路径,所以只在预览这里补。
function buildPreviewHtml() {
var dir = location.href.replace(/[?#].*$/, '').replace(/[^/]*$/, '');
var html;
// previewMode 只在生成这一份的时候开着(见它的注释),生成完必须
// 复位,否则之后「获取代码」出来的也变成没有分割条的版本了
previewMode = true;
try {
html = buildPageCode();
} finally {
previewMode = false;
}
return html.replace('<head>', '<head>\n<base href="' + dir + '" />');
}
// 往 iframe 里写文档。用 document.write 而不是 srcdoc:预览要真的把
// Ext 跑起来,老浏览器不认 srcdoc 的话预览会是一片空白。
function writePreviewFrame(iframeEl, html) {
var doc = iframeEl.contentWindow.document;
doc.open();
doc.write(html);
doc.close();
}
// 复制到剪贴板。navigator.clipboard 只在 https / localhost 下给用,
// 拿不到就退回 textarea + execCommand 的老办法
function copyCode(text) {
function legacy() {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
var done = false;
try { done = document.execCommand('copy'); } catch (e) { done = false; }
document.body.removeChild(ta);
return done;
}
function tell(done) {
Ext.Msg.alert('提示', done ? '代码已复制到剪贴板'
: '复制失败,请在代码框里手动选中复制');
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
function() { tell(true); },
function() { tell(legacy()); });
} else {
tell(legacy());
}
}
// 「获取代码」:整页代码放进 CodeMirror 只读展示。这里改了也回不到
// 画布上,能编辑只会误导,要改就复制出去改。
function showCodeWindow() {
var code = buildPageCode();
// 代码靠面板实例拿着,别用 id/itemId 去查:窗口可能被反复打开,
// id 会撞上上一次已经销毁的那个实例
var host = new Ext.Panel({ border: false });
var win = new Ext.Window({
title: '页面代码',
width: 780,
height: 620,
layout: 'fit',
plain: true,
maximizable: true,
modal: true,
items: [host],
buttons: [
{ text: '复制', handler: function() { copyCode(code); } },
{ text: '关闭', handler: function() { win.close(); } }
]
});
win.show();
// show() 之后面板 body 才存在,CodeMirror 只能建在它里面
var cm = CodeMirror(host.body.dom, {
value: code,
mode: 'javascript',
readOnly: true, // 只读但保留光标,方便全选复制
lineNumbers: true,
lineWrapping: false,
// 生成的是层层嵌套的 items 数组,能折叠起来看结构方便很多。
// gutters 必须把行号槽一起列上,只写折叠加会连行号一起没掉
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
foldGutter: true
});
// 窗口拉伸 / 最大化之后 CodeMirror 不会自己重新量尺寸,得推一把
win.on('resize', function() { cm.refresh(); });
return win;
}
// 「预览」:把生成的代码真的跑起来。iframe 里写的就是「获取代码」看到的
// 那一份,只在 head 多一行 <base>(见 buildPreviewHtml)
function showPreviewWindow() {
// 不给 src:不写 src 的 iframe 就是 about:blank,同源、能直接写
// 文档;写上 src="about:blank" 反而会多一次隐式加载,可能把刚写
// 进去的内容冲掉
var frame = new Ext.BoxComponent({
autoEl: { tag: 'iframe', cls: 'designer-preview-frame', frameBorder: '0' }
});
function render() {
// 每次刷新都重新生成:这期间画布可能已经改过了
writePreviewFrame(frame.el.dom, buildPreviewHtml());
}
function openStandalone() {
// 独立窗口同样要带 <base>,否则相对路径还是解析不到
var w = window.open('', '_blank');
if (!w) {
Ext.Msg.alert('提示', '浏览器拦截了新窗口,请允许弹出窗口后重试');
return;
}
w.document.open();
w.document.write(buildPreviewHtml());
w.document.close();
}
var win = new Ext.Window({
title: '预览',
width: 900,
height: 640,
layout: 'fit',
plain: true,
maximizable: true,
items: [frame],
buttons: [
{ text: '刷新', handler: render },
{ text: '在新窗口打开', handler: openStandalone },
{ text: '关闭', handler: function() { win.close(); } }
]
});
// render 事件触发时 iframe 已经进了 DOM,contentWindow 才拿得到
frame.on('render', render);
win.show();
return win;
}
function renderItems(collection, depth) {
var pad = new Array(depth * 2 + 1).join(' ');
var out = '';
collection.each(function(item) {
out += pad + renderItem(item, depth) + ',\n';
});
return out;
}
// 下拉框的数据源要还原成 ArrayStore:Ext 的 ComboBox 只认 Store 实例或
// storeId,直接给数组是跑不起来的
function comboStoreLiteral(store) {
var rows = [];
if (store && store.each) {
store.each(function(rec) {
rows.push("['" + esc(rec.get('value')) + "', '" + esc(rec.get('text')) + "']");
});
}
return "new Ext.data.ArrayStore({ fields: ['value', 'text'], data: [" + rows.join(', ') + '] })';
}
// ---- 表格类组件的代码还原 ----
// 生成代码里 store 必须是真 Store 实例,给数组是跑不起来的。字段名和
// 行数据都从画布上那个 store 里回读(Store#fields 就是字段的
// MixedCollection),所以画布上的数据变了,导出的页面跟着变。
function jsLiteral(v) {
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
if (v == null) return "''";
return "'" + esc(v) + "'";
}
function arrayStoreLiteral(store) {
var fields = (store && store.fields && store.fields.keys) ? store.fields.keys : [];
var rows = [];
if (store && store.each) {
store.each(function(rec) {
var row = [];
for (var i = 0; i < fields.length; i++) row.push(jsLiteral(rec.get(fields[i])));
rows.push('[' + row.join(', ') + ']');
});
}
var names = [];
for (var j = 0; j < fields.length; j++) names.push("'" + esc(fields[j]) + "'");
return 'new Ext.data.ArrayStore({ fields: [' + names.join(', ') +
'], data: [' + rows.join(', ') + '] })';
}
// 图表的数据还原:一串 { label, value }。label 一律按字符串写,
// 散点图的数值标签('12')在 Ext.ux.Chart 里会用 Number() 转回数字
function chartDataLiteral(rows) {
var out = [];
Ext.each(rows || [], function(r) {
var v = isFinite(r.value) ? r.value : 0;
out.push("{ label: '" + esc(r.label) + "', value: " + v + ' }');
});
return '[' + out.join(', ') + ']';
}
function columnLiteral(c) {
var p = [];
if (c.header != null) p.push("header: '" + esc(c.header) + "'");
if (c.dataIndex != null) p.push("dataIndex: '" + esc(c.dataIndex) + "'");
p.push('width: ' + (c.width || 100));
if (c.align) p.push("align: '" + esc(c.align) + "'");
return '{ ' + p.join(', ') + ' }';
}
function columnsLiteral(list) {
var out = [];
for (var i = 0; i < list.length; i++) out.push(columnLiteral(list[i]));
return '[' + out.join(', ') + ']';
}
// 普通表格用 ColumnModel 里的列(构造时已经补过默认值,就是画布上的样子),
// 树形表格的列在 initColumns 里被换成了 Ext.tree.Column 实例,属性同名
function gridColumnsLiteral(item) {
var cfg = (item.colModel && item.colModel.config) ? item.colModel.config : (item.columns || []);
return columnsLiteral(cfg);
}
// 高级表格的工具条配置(生成代码用)。设计器里那份是活的控件实例,这里
// 拼的是配置字面量,但两边的构成规则都出自 advGridTbarItems:每列一个
// tbtext 加一个带 name 的输入框(类型也按同一份推断),再是查询 / 重置。
// 两个 handler 直接用 String(函数) 把源码内联进去,导出页点「查询」的行为
// 跟设计器里完全一样,不会各写一遍走样
// 具名函数 String() 出来是带着函数名的(function advQueryHandler(btn)…),
// 名字是设计器的内部实现,导出的页面里不该出现,去掉名字就是个匿名函数
// 表达式,一样能跑
function fnSource(fn) {
return String(fn).replace(/^function\s+[A-Za-z_$][\w$]*/, 'function ');
}
function advGridTbarCode(item, depth) {
var pad = new Array(depth * 2 + 1).join(' ');
var inner = pad + ' ';
var columns = advGridColumns(item);
var store = item.getStore ? item.getStore() : item.store;
var lines = [], any = false;
Ext.each(advGridSearchCols(item), function(di) {
var col = advColumnByIndex(columns, di);
any = true;
lines.push("{ xtype: 'tbtext', text: '" +
esc((col && col.header ? col.header : di) + ':') + "' }");
lines.push("{ xtype: '" + advSearchFieldType(store, di) +
"', width: 120, name: '" + esc(di) + "' }");
});
if (any) lines.push("'-'");
lines.push("{ text: '查询', handler: " + fnSource(advQueryHandler) + " }");
lines.push("{ text: '重置', handler: " + fnSource(advResetHandler) + " }");
return 'tbar: [\n' + inner + lines.join(',\n' + inner) + '\n' + pad + ']';
}
// 「列请求地址」配了的话,导出页也要自己去取一次列定义——不然导出的页面
// 只剩设计器里那一份写死的列。用 afterrender 而不是 render:表头样式那边
// 也可能挂一条 render 监听,同一个对象里两个 render 键只有最后一个算数
function advGridColUrlListener(item) {
return "afterrender: function(g) { Ext.Ajax.request({ url: '" + esc(item._columnUrl) +
"', success: function(resp) { var c; try { c = Ext.decode(resp.responseText); }" +
" catch (e) { return; } if (Ext.isArray(c) && c.length) {" +
" g.reconfigure(g.getStore(), new Ext.grid.ColumnModel(c)); } } }); }";
}
// 树形表格的节点树还原成配置字面量。id / uiProvider / loader 都是 Ext
// 运行时塞进去的(loader 还指向已经销毁的那个 loader 对象),不能写进代码;
// 其余属性就是各个列要显示的字段值
var NODE_SKIP_KEYS = ['id', 'uiProvider', 'loader', 'children', 'text', 'expanded',
'leaf', 'allowChildren', 'icon', 'iconCls', 'cls', 'qtip',
'hidden', 'checked', 'disabled', 'draggable', 'singleClickExpand'];
function treeNodeLiteral(node) {
var attrs = node.attributes || {};
var props = ["text: '" + esc(node.text) + "'"];
for (var k in attrs) {
if (NODE_SKIP_KEYS.indexOf(k) !== -1) continue;
if (typeof attrs[k] === 'function') continue;
if (attrs[k] === undefined) continue;
props.push(k + ': ' + jsLiteral(attrs[k]));
}
// 展开状态照画布抄:生成出来的页面和画布长得一样
if (node.isExpanded && node.isExpanded()) props.push('expanded: true');
var kids = [];
if (node.eachChild) node.eachChild(function(c) { kids.push(treeNodeLiteral(c)); });
if (kids.length) props.push('children: [' + kids.join(', ') + ']');
// 没有子节点就得标 leaf,否则节点前面挂着展开图标,点下去既没数据
// 也没回调(loader 没有 dataUrl),会一直转
else if (node.isLeaf && node.isLeaf()) props.push('leaf: true');
return '{ ' + props.join(', ') + ' }';
}
// 交叉表轴:initAxes 会把配置转成 PivotAxis 实例,维度留在 dimensions 上
function pivotAxisLiteral(axis) {
var dims = (axis && axis.dimensions) ? axis.dimensions : (axis || {});
var out = [];
for (var i = 0; i < dims.length; i++) {
var p = [];
if (dims[i].dataIndex != null) p.push("dataIndex: '" + esc(dims[i].dataIndex) + "'");
if (dims[i].width != null) p.push('width: ' + dims[i].width);
out.push('{ ' + p.join(', ') + ' }');
}
return '[' + out.join(', ') + ']';
}
// 区域面板的尺寸位置全由 border 布局说了算,容器上配的「组件间距」对它们无效,
// 生成代码时也就不能写 defaults,否则导出的效果和画布对不上
function hasRegionChildren(item) {
var found = false;
// items 只有渲染过的容器才是 MixedCollection,未渲染时可能是 config 数组
if (item && item.items && item.items.each) {
item.items.each(function(c) { if (c._isDesignerRegion) found = true; });
}
return found;
}
// 画布上真实的外边距。designerEl 给出的可能就是字段外层那个 wrap,外边距
// 正是挂在它身上(见 normalizeItemMargin)。归一化过的 10px 和容器「组件
// 间距」都是直接写在内联样式里的,getStyle 原样取得回来;区域面板没有外边距
// (它是 border 布局摆的),算出来是 0px,这时就不写。
function itemMarginCss(item) {
var el = designerEl(item);
if (!el || !el.dom) return null;
var m = el.getStyle('margin');
if (!m || m === '0px' || m === 'auto') return null;
return m;
}
function renderItem(item, depth) {
var pad = new Array(depth * 2 + 1).join(' ');
var t = item.getXType();
var props = ["xtype: '" + t + "'"];
var listeners = []; // 最后合成一条 listeners
var elCss = []; // 合成 style(落在组件外层元素上)
var bodyCss = []; // 合成 bodyStyle(落在 body 上)
// ---- 结构性的东西:整块拼出来的字面量(列定义、store、坐标轴),
// 没法拆成「一个属性一个值」写进 PROP_SPECS ----
if (item.isFormField) {
if (t === 'radio' && item.inputValue != null) props.push("inputValue: '" + esc(item.inputValue) + "'");
if (t === 'combo') {
props.push("mode: '" + esc(item.mode || 'local') + "'");
props.push("triggerAction: '" + esc(item.triggerAction || 'all') + "'");
if (item.valueField) props.push("valueField: '" + esc(item.valueField) + "'");
if (item.displayField) props.push("displayField: '" + esc(item.displayField) + "'");
props.push('store: ' + comboStoreLiteral(item.store));
}
// TriggerField 不实现 onTriggerClick 的话,点扳机就会抛异常
if (t === 'trigger') props.push('onTriggerClick: Ext.emptyFn');
} else if (t === 'uxchart') {
// 图表标题走 Chart.js 的 title 插件,不是面板表头,所以不归
// 「容器」那一支(参数类型和落点都不一样)
if (item.title) props.push("title: '" + esc(item.title) + "'");
} else if (t === 'grid' || t === 'pivotgrid' || t === 'treegrid') {
if (item.title) props.push("title: '" + esc(item.title) + "'");
if (t === 'treegrid') {
// loader 只负责把嵌套节点建出来,数据全在 root 上
props.push('loader: { preloadChildren: true }');
props.push('root: new Ext.tree.AsyncTreeNode(' + treeNodeLiteral(item.getRootNode()) + ')');
props.push('columns: ' + columnsLiteral(item.columns || []));
} else {
props.push('store: ' + arrayStoreLiteral(item.getStore ? item.getStore() : item.store));
if (t === 'pivotgrid') {
props.push('leftAxis: ' + pivotAxisLiteral(item.leftAxis));
props.push('topAxis: ' + pivotAxisLiteral(item.topAxis));
// viewConfig.title 是左上角那格的字
var corner = (item.viewConfig && item.viewConfig.title) || '';
props.push("viewConfig: { title: '" + esc(corner) + "' }");
// 行列表头和左上角标题只有 refresh(true) 会画,
// 少了这句导出的页面顶部一片空白
listeners.push('viewready: function(g) { g.view.refresh(true); }');
} else {
props.push('columns: ' + gridColumnsLiteral(item));
}
}
// 高级表格:自带的那条搜索工具条(结构性的,跟着列走)
if (isAdvGrid(item)) props.push(advGridTbarCode(item, depth));
// 配了列请求地址,导出页也照着自己取一次
if (isAdvGrid(item) && item._columnUrl) {
listeners.push(advGridColUrlListener(item));
}
} else {
if (item._designerLayout) props.push("layout: '" + esc(item._designerLayout) + "'");
if (item.title) props.push("title: '" + esc(item.title) + "'");
// 属性表里把表头设成「隐藏」的,生成代码要带上 header: false
if (item._headerHidden) props.push('header: false');
// 面板默认没有边框,这件事得写进代码:Ext 的 Panel 默认是
// border: true,不写的话导出的页面里会凭空多出一圈蓝边
if (item.border === false) props.push('border: false');
}
// ---- 属性表里配出来的那些行,全在 PROP_SPECS 那张表里:一个属性
// 一个值,或者一段 css 片段。什么时候有这一项由 spec.when 说 ----
Ext.each(PROP_SPECS, function(spec) {
if (spec.when && !spec.when(item)) return;
var frag = spec.code ? spec.code(item) : null;
if (frag) props.push(frag);
if (!spec.cssOf) return;
var css = spec.cssOf(item);
if (!css) return;
if (spec.cssTarget === 'body') bodyCss.push(css);
else if (spec.cssTarget === 'both') { elCss.push(css); bodyCss.push(css); }
else elCss.push(css);
});
// 标题栏样式:Ext 3.4 的 Panel 其实不认 headerCfg(源码里只判断它
// 存不存在,从没拿它配过什么),表头又是渲染时按 title 一次性建出来
// 的,只能挂个 render 监听,等表头建好再把样式补上去
var titleCss = [];
if (item._titleAlign) titleCss.push('text-align: ' + item._titleAlign + ';');
if (item._titleSize) titleCss.push('font-size: ' + item._titleSize + 'px;');
if (item._titleColor) titleCss.push('color: ' + item._titleColor + ';');
if (titleCss.length && hasHeader(item)) {
// 必须用 applyStyles:setStyle 只认「属性名, 值」或对象,喂它整串
// CSS 会被当成一个属性名赋给 style,静悄悄地什么都不做
listeners.push("render: function(p) { var t = p.header && " +
"p.header.child('span.x-panel-header-text'); if (t) t.applyStyles('" +
titleCss.join(' ') + "'); }");
}
// 分割区域:region 是 border 布局识别子项的唯一依据,必须带上
if (item._isDesignerRegion) {
props.push("region: '" + item.region + "'");
if (item.region === 'north' || item.region === 'south') props.push('height: ' + (item.height || 110));
else if (item.region !== 'center') props.push('width: ' + (item.width || 170));
// 预览模式不写 split,见 previewMode 的注释
if (item.split && !previewMode) {
props.push('split: true');
props.push('minSize: ' + (item.minSize || 30));
}
} else {
// 画布上量到的尺寸要跟着代码走。Ext 的 auto 布局不会给子项定尺寸,
// 不写 width/height,组件在预览里就会撑满整行;而分割过的面板是
// border 布局,容器自己没有高度就只剩一条标题栏,区域全都不显示。
// 区域面板例外(尺寸由 border 布局决定,上面刚写过 region/width/height)。
if (typeof item.width === 'number' && item.width > 0) {
props.push('width: ' + Math.round(item.width));
}
if (typeof item.height === 'number' && item.height > 0) {
props.push('height: ' + Math.round(item.height));
}
}
// 画布上真实的外边距(归一化的 10px 或所在容器的「组件间距」)。
// 容器的 defaults 已经发过一次同样的值,这里再写一份并不冲突,
// 反倒是容器没有 defaults 时(非间距容器)唯一的来源
var marginCss = itemMarginCss(item);
if (marginCss) elCss.push('margin: ' + marginCss + ';');
// 合成一条 style / bodyStyle。分开写的话后一个会把前一个顶掉
// (对象字面量里同名的键只有最后一个算数),外边距就丢了
if (elCss.length) props.push("style: '" + elCss.join(' ') + "'");
if (bodyCss.length) props.push("bodyStyle: '" + bodyCss.join(' ') + "'");
if (listeners.length) props.push('listeners: { ' + listeners.join(', ') + ' }');
if (item.items && item.items.getCount && item.items.getCount() > 0) {
props.push('items: [\n' + renderItems(item.items, depth + 1) + pad + ']');
}
return '{ ' + props.join(', ') + ' }';
}
function esc(s) {
return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}
// ================= 开箱即用 =================
// 中间画布进来就有一个面板当页面容器(不带边框,布局方式在右侧属性窗口
// 里改),而且是选中状态:右边的属性表一上来就有东西可配,不必先从左边
// 拖一个进来。
// 必须放在 onReady 这一块的最末尾:建组件会走到选中、缩放把手这些逻辑,
// 它们依赖的 canvasBody、resizeHost/resizeTrackers、属性表都得先初始化
// 完(那几个 var 都在下面才赋值)。Window 在这里也已经渲染过了,画布
// body 是有尺寸的。
createDefaultCanvasPanel();
});
</script>
</head>
<body>
</body>
</html>