Files
workspace/code/fms/form-designer-demo.html
T
2026-09-27 21:59:13 +08:00

1634 lines
53 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>表单设计器 · 布局手感 Demo(v2 心智)</title>
<!--
表单设计器布局交互 Demo(单文件、零依赖,双击即可打开)。
验证目标(v2 心智):
1. 两级列数各司其职:
- 分组宽度(外层):整行 / 1/2 / 1/3 / 1/4 —— 一行并排几个分组
- 组内列数(组内基准):1/2/3/4/6 列 —— 字段宽度以「跨几列」为单位(每列 = 24/列数 格)
2. 改宽度不输入数字:拖卡片右边缘 / 点档位 / 键盘,都按「列」吸附
3. 只改自己:插入 / 删除 / 改宽都不自动挤压或均分别人的宽度(均分降级为显式按钮)
4. 容器(分组)用"列数"表达:1-4 列,外层 48 格是内部实现
迁移落点(demo 手感确认后再搬到项目):
- 拖右边缘改宽 → FormDesignEditor.vue 画布卡片 + useFormGrid.js 新增 resizeSpanByDrag()
- 浮动工具条档位 → FormInspector.vue 的宽度区块(替换现有数字框 + 档位按钮)
- 属性面板刻度条 → FormInspector.vue
- 组内列数(基准) → 分组节点加 cols,字段宽度以「跨几列」表达;改列数时按比例换算 span
(见本文件的 setGroupInnerCols),存储仍是 24 制整数
- 关掉自动均分 → 停用 rebalanceInsert / onColspanChange 里的压缩 / removeRow 里的回收,
改为「占满本行」「平分本行」两个显式命令(复用 distributeLine)
- 列参考线 → FormDesignEditor.vue 的 .mm-fdesign__grid 覆盖层
- 行填充度 → 分组标题栏 meta(本行 xx/24 · 剩 x)
- 插入槽 → 替换现在 before/after/newline/line-before 四态落点提示
- 键盘 → onDesignerKeydown 里新增 Shift+←/→(±1)、Shift+Alt+←/→(跳档)
-->
<style>
:root {
--bg: #f4f5f7;
--card: #ffffff;
--line: #e3e6eb;
--line-soft: #eef1f5;
--text: #1f2d3d;
--text-2: #6b7280;
--text-3: #9aa3af;
--primary: #2f6fed;
--primary-soft: rgba(47, 111, 237, 0.08);
--danger: #d9534f;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: 13px;
line-height: 1.5;
}
body.is-dragging {
user-select: none;
cursor: grabbing;
}
/* ================= 顶栏 ================= */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
height: 48px;
padding: 0 14px;
background: var(--card);
border-bottom: 1px solid var(--line);
}
.topbar__title {
font-size: 14px;
font-weight: 600;
}
.topbar__actions {
display: flex;
align-items: center;
gap: 8px;
}
.btn {
height: 28px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--card);
color: var(--text);
font-family: inherit;
font-size: 12px;
cursor: pointer;
}
.btn:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
}
.btn:disabled {
color: var(--text-3);
cursor: not-allowed;
}
.btn.is-on {
border-color: var(--primary);
background: var(--primary-soft);
color: var(--primary);
}
.topbar__tips {
color: var(--text-2);
font-size: 12px;
}
.topbar__tips kbd {
padding: 1px 5px;
border: 1px solid var(--line);
border-bottom-width: 2px;
border-radius: 4px;
background: #fafbfc;
font-family: inherit;
font-size: 11px;
}
/* ================= 三栏 ================= */
.workspace {
display: grid;
grid-template-columns: 208px minmax(0, 1fr) 268px;
height: calc(100vh - 48px);
}
.lib,
.inspector {
overflow: auto;
background: var(--card);
}
.lib {
border-right: 1px solid var(--line);
padding: 10px;
}
.inspector {
border-left: 1px solid var(--line);
padding: 10px;
}
.panel-title {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
}
.panel-title span {
color: var(--text-3);
font-size: 11px;
font-weight: 400;
}
.lib-search {
width: 100%;
height: 28px;
margin-bottom: 8px;
padding: 0 8px;
border: 1px solid var(--line);
border-radius: 6px;
font-family: inherit;
font-size: 12px;
outline: none;
}
.lib-search:focus {
border-color: var(--primary);
}
.lib-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
width: 100%;
min-height: 28px;
margin-bottom: 2px;
padding: 0 8px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--text);
font-family: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.lib-item:hover {
background: var(--primary-soft);
color: var(--primary);
}
.lib-item.is-added {
color: var(--text-2);
}
.lib-item.is-selected {
background: var(--primary-soft);
color: var(--primary);
}
.lib-item__flag {
flex: none;
color: #146c41;
font-size: 11px;
}
/* ================= 画布 ================= */
.canvas-wrap {
overflow: auto;
padding: 14px;
}
.canvas {
display: grid;
grid-template-columns: repeat(48, minmax(0, 1fr));
align-items: start;
gap: 12px;
}
.group {
min-width: 0;
padding: 10px 12px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--card);
}
.group__head {
display: flex;
flex-wrap: wrap; /* 分组被压到 1/4 行时,两个下拉 + 按钮允许换行 */
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.group__title {
font-size: 12px;
font-weight: 600;
}
.group__title.is-empty {
color: var(--text-3);
font-weight: 400;
}
.group__meta {
display: flex;
align-items: center;
gap: 6px;
}
.group__fill {
color: var(--primary);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.group__cols {
height: 24px;
padding: 0 4px;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--card);
color: var(--text);
font-family: inherit;
font-size: 11px;
cursor: pointer;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 24px;
padding: 0;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--card);
color: var(--text-2);
font-family: inherit;
font-size: 12px;
cursor: pointer;
}
.icon-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
/* 组内 24 格;padding 放在 .group 上,保证这里就是 24 格的完整宽度 */
.group-body {
position: relative;
display: grid;
grid-template-columns: repeat(24, minmax(0, 1fr));
gap: 12px;
min-height: 56px;
}
/* 列参考线:与字段共用同一套 grid,所以与卡片边界严格对齐;
单行铺满整组高度 → 竖线贯穿分组,多行也能对齐 */
.guides {
position: absolute;
inset: 0;
z-index: 0;
display: grid;
grid-template-columns: repeat(24, minmax(0, 1fr));
grid-template-rows: 100%;
gap: 12px;
pointer-events: none;
}
.guides i {
border-left: 1px dashed rgba(47, 111, 237, 0.22);
}
.group-body.is-drop-target {
border-radius: 6px;
outline: 1px dashed rgba(47, 111, 237, 0.5);
outline-offset: 4px;
}
.group-empty {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
border: 1px dashed var(--line);
border-radius: 6px;
color: var(--text-3);
font-size: 12px;
}
/* ================= 字段卡片 ================= */
.field {
position: relative;
z-index: 1;
min-width: 0;
padding: 7px 9px 9px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--card);
cursor: default;
}
.field:hover {
z-index: 2;
border-color: #c9d3e3;
cursor: grab;
}
/* 选中卡片整体抬到最上层:.field 自带 z-index 就是层叠上下文,
工具条写在卡片内部,不把卡片抬起来就会被后面的兄弟卡片盖住 */
.field.is-selected {
z-index: 30;
border-color: var(--primary);
box-shadow: 0 0 0 2px var(--primary-soft);
}
.field.is-dragging {
opacity: 0.4;
}
.field__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
min-width: 0;
}
.field__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.field__name i {
margin-left: 2px;
color: var(--danger);
font-style: normal;
}
.field__chip {
flex: none;
padding: 1px 5px;
border-radius: 4px;
background: #f3f4f6;
color: var(--text-2);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.field.is-selected .field__chip {
background: var(--primary-soft);
color: var(--primary);
}
.field__ctrl {
display: flex;
align-items: center;
height: 26px;
margin-top: 6px;
padding: 0 8px;
border: 1px solid var(--line-soft);
border-radius: 4px;
background: #fafbfc;
color: var(--text-3);
font-size: 12px;
overflow: hidden;
white-space: nowrap;
}
/* 右边缘拖拽手柄(唯一的手柄:只改自己,不挤压别人) */
.field__resize {
position: absolute;
top: 0;
right: -4px;
width: 9px;
height: 100%;
cursor: col-resize;
}
.field__resize::after {
content: '';
position: absolute;
top: 8px;
bottom: 8px;
right: 4px;
width: 2px;
border-radius: 1px;
background: transparent;
}
.field:hover .field__resize::after {
background: #cfd8e6;
}
.field__resize:hover::after,
.field__resize.is-active::after {
background: var(--primary);
}
/* ================= 浮动工具条 ================= */
.toolbar {
position: absolute;
top: calc(100% + 6px);
left: 0;
display: flex;
align-items: center;
gap: 3px;
padding: 4px 5px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--card);
box-shadow: 0 8px 20px rgba(31, 45, 61, 0.14);
white-space: nowrap;
}
/* 靠右列的卡片:工具条右对齐,避免被画布容器裁掉(渲染后由脚本判断) */
.toolbar.is-flip {
left: auto;
right: 0;
}
.tb-btn {
height: 24px;
padding: 0 7px;
border: 1px solid transparent;
border-radius: 5px;
background: transparent;
color: var(--text);
font-family: inherit;
font-size: 11px;
cursor: pointer;
}
.tb-btn:hover {
background: var(--primary-soft);
color: var(--primary);
}
.tb-btn.is-active {
border-color: var(--primary);
background: var(--primary-soft);
color: var(--primary);
font-weight: 600;
}
.tb-btn.is-danger:hover {
background: rgba(217, 83, 79, 0.1);
color: var(--danger);
}
.tb-sep {
width: 1px;
height: 16px;
margin: 0 2px;
background: var(--line);
}
/* ================= 属性面板 ================= */
.prop {
margin-top: 10px;
}
.prop > label {
display: block;
margin-bottom: 4px;
color: var(--text-2);
font-size: 11px;
}
.prop-static {
display: flex;
align-items: center;
height: 28px;
padding: 0 8px;
border: 1px solid var(--line);
border-radius: 4px;
background: #fafbfc;
font-size: 12px;
}
.scale {
display: grid;
/* 刻度格数 = 组内列数,由脚本内联设置(1-6 格) */
gap: 1px;
height: 18px;
padding: 2px;
border: 1px solid var(--line);
border-radius: 4px;
background: #fafbfc;
cursor: pointer;
}
.scale__cell {
border-radius: 1px;
background: #e6e9ef;
}
.scale__cell.is-on {
background: var(--primary);
}
.span-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
}
.span-row .btn {
width: 28px;
padding: 0;
font-size: 13px;
line-height: 1;
}
.span-row input {
width: 56px;
height: 28px;
padding: 0 6px;
border: 1px solid var(--line);
border-radius: 4px;
font-family: inherit;
font-size: 12px;
text-align: center;
outline: none;
}
.span-row .span-hint {
color: var(--text-3);
font-size: 11px;
}
.inspector select {
width: 100%;
height: 28px;
padding: 0 6px;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--card);
color: var(--text);
font-family: inherit;
font-size: 12px;
cursor: pointer;
}
.switch-line {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 26px;
font-size: 12px;
}
.inspector__empty {
color: var(--text-2);
font-size: 12px;
}
.inspector__empty ul {
margin: 8px 0 0;
padding-left: 16px;
}
.inspector__empty li {
margin-bottom: 4px;
}
.split-actions {
display: flex;
gap: 6px;
margin-top: 10px;
}
.split-actions .btn {
flex: 1;
min-width: 0;
padding: 0 4px;
}
/* ================= 拖动指示(悬浮层:永远压在画布与工具条之上) ================= */
.drop-line {
position: fixed;
z-index: 900;
width: 2px;
border-radius: 1px;
background: var(--primary);
pointer-events: none;
display: none;
}
.drag-label {
position: fixed;
z-index: 910;
display: none;
padding: 3px 10px;
border-radius: 12px;
background: var(--primary);
color: #fff;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
pointer-events: none;
box-shadow: 0 2px 8px rgba(47, 111, 237, 0.35);
}
.bubble {
position: fixed;
z-index: 920;
display: none;
padding: 4px 9px;
border-radius: 6px;
background: rgba(31, 45, 61, 0.9);
color: #fff;
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.bubble b {
font-size: 12px;
}
</style>
</head>
<body>
<header class="topbar">
<div class="topbar__title">表单设计器 · 布局手感 Demo</div>
<div class="topbar__tips">
拖卡片右边缘按<strong>列</strong>吸附 · <kbd>Shift</kbd>+<kbd>←/→</kbd> 跨列 ±1 ·
<kbd>Shift</kbd>+<kbd>Alt</kbd>+<kbd>←/→</kbd> 自由 ±1 格 · <kbd>Alt</kbd> 拖动 = 不吸附到列
</div>
<div class="topbar__actions">
<button class="btn" id="btn-undo" title="Ctrl+Z">撤销</button>
<button class="btn" id="btn-redo" title="Ctrl+Shift+Z / Ctrl+Y">重做</button>
<button class="btn is-on" id="btn-guides" title="选中字段时显示 24 列参考线">参考线</button>
</div>
</header>
<main class="workspace">
<aside class="lib">
<div class="panel-title">字段库 <span>点击加入 / 定位</span></div>
<input class="lib-search" id="lib-search" placeholder="搜索字段名" />
<div id="lib-list"></div>
</aside>
<section class="canvas-wrap">
<div class="canvas" id="canvas"></div>
</section>
<aside class="inspector" id="inspector"></aside>
</main>
<div class="drop-line" id="drop-line"></div>
<div class="drag-label" id="drag-label"></div>
<div class="bubble" id="bubble"></div>
<script>
/* ============================================================
* 1. 常量与工具
* ============================================================ */
const GRID = 24 // 组内字段栅格(内部存储单位,界面只说「列 / 分数」)
const GROUP_GRID = 48 // 外层分组栅格(内部存储单位,界面只说「一行几个分组」)
/** 分组宽度:一行并排几个分组(外层 48 格 → 48/份数) */
const GROUP_WIDTHS = [1, 2, 3, 4]
/** 组内列数:字段宽度的基准(每列 = 24/列数 格;1/2/3/4/6 都能整除 24) */
const GROUP_INNER_COLS = [1, 2, 3, 4, 6]
/** 分组宽度文案:外层并排份数 → 一行放几个 */
const FILL_LABELS = { 1: '整行', 2: '1/2 行', 3: '1/3 行', 4: '1/4 行' }
const clamp = (v, min, max) => Math.min(Math.max(v, min), max)
const sum = (list, pick) => list.reduce((acc, item) => acc + pick(item), 0)
function gcd(a, b) {
return b ? gcd(b, a % b) : a
}
/** 24 制格数 → 分数文案(8 → 1/3、16 → 2/3、9 → 3/8) */
function fmtSpan(span) {
if (span >= GRID) return '整行'
const g = gcd(span, GRID)
return `${span / g}/${GRID / g}`
}
function escapeHtml(text) {
return String(text).replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c],
)
}
const $ = (sel) => document.querySelector(sel)
/* ============================================================
* 2. 状态(v2 心智:容器列数 + 字段占几份,存储仍是 24 制)
* ============================================================ */
const LIB = [
'姓名',
'性别',
'手机号',
'电子邮箱',
'身份证号',
'部门',
'岗位',
'职级',
'入职日期',
'备注',
'通讯地址',
'紧急联系人',
]
const state = {
groups: [
// width = 外层并排份数(一行放几个分组);cols = 组内列数(字段宽度的基准)
{ id: 'g1', title: '基本信息', width: 2, cols: 3 },
{ id: 'g2', title: '任职信息', width: 2, cols: 6 },
],
fields: [
// g1 是 3 列(每列 8 格):姓名 1 列、性别 1 列、身份证 2 列
{ key: 'f1', label: '姓名', group: 'g1', span: 8, required: 1 },
{ key: 'f2', label: '性别', group: 'g1', span: 8 },
{ key: 'f3', label: '身份证号', group: 'g1', span: 16 },
{ key: 'f4', label: '手机号', group: 'g1', span: 8, required: 1 },
{ key: 'f5', label: '电子邮箱', group: 'g1', span: 16 },
{ key: 'f6', label: '备注', group: 'g1', span: 24 },
// g2 是 6 列(每列 4 格):能玩出 4(1 列)这种最窄档
{ key: 'f7', label: '部门', group: 'g2', span: 4 },
{ key: 'f8', label: '岗位', group: 'g2', span: 8 },
{ key: 'f9', label: '入职日期', group: 'g2', span: 4 },
{ key: 'f10', label: '职级', group: 'g2', span: 4 },
{ key: 'f11', label: '紧急联系人', group: 'g2', span: 12 },
],
selectedKey: null,
showGuides: true,
libSearch: '',
seq: 100,
}
let nextKey = () => `f${++state.seq}`
/* ============================================================
* 3. 历史(快照式撤销,迁移时换成 action 栈)
* ============================================================ */
const HISTORY_LIMIT = 80
let past = []
let future = []
function snapshot() {
return JSON.stringify({ groups: state.groups, fields: state.fields })
}
function pushHistory() {
past.push(snapshot())
if (past.length > HISTORY_LIMIT) past.shift()
future = []
renderTopbar()
}
function applySnapshot(text) {
const parsed = JSON.parse(text)
state.groups = parsed.groups
state.fields = parsed.fields
if (!state.fields.some((f) => f.key === state.selectedKey)) state.selectedKey = null
renderAll()
}
function undo() {
const prev = past.pop()
if (!prev) return
future.push(snapshot())
applySnapshot(prev)
}
function redo() {
const next = future.pop()
if (!next) return
past.push(snapshot())
applySnapshot(next)
}
/* ============================================================
* 4. 查找与推导
* ============================================================ */
const fieldOf = (key) => state.fields.find((f) => f.key === key) || null
const groupOf = (id) => state.groups.find((g) => g.id === id) || null
const groupFields = (gid) => state.fields.filter((f) => f.group === gid)
/** 组内列数:容器的布局基准(组的布局都从它派生) */
function innerColsOf(gid) {
const group = groupOf(gid)
return group && GROUP_INNER_COLS.includes(group.cols) ? group.cols : 1
}
/** 组内每列占几格(24 制):列数 1/2/3/4/6 → 每列 24/12/8/6/4 格 */
const colUnitOf = (gid) => GRID / innerColsOf(gid)
/** 字段跨几列 */
const colsOf = (item) => Math.round(item.span / colUnitOf(item.group))
/** 按顺序打包 24 格 → 视觉行(与项目 collectLineIndexes 同一规则) */
function linesOf(gid) {
const lines = []
let cur = []
let used = 0
for (const f of groupFields(gid)) {
if (used + f.span > GRID && cur.length) {
lines.push(cur)
cur = []
used = 0
}
cur.push(f)
used += f.span
}
if (cur.length) lines.push(cur)
return lines
}
/** 字段所在视觉行 */
function lineMembers(key) {
const item = fieldOf(key)
if (!item) return []
return linesOf(item.group).find((line) => line.some((f) => f.key === key)) || [item]
}
/** 分组标题栏的「基准列数 + 本行占格」:只在选中字段属于该组时显示 */
function fillText(gid) {
const item = state.selectedKey ? fieldOf(state.selectedKey) : null
if (!item || item.group !== gid) return ''
const line = lineMembers(item.key)
const used = sum(line, (f) => f.span)
return `本行 ${used}/24 · 剩 ${GRID - used}`
}
/* ============================================================
* 5. 变更(全部先 pushHistory,且都不改别人的宽度)
* ============================================================ */
function setSpan(key, span, { silentHistory = false } = {}) {
const item = fieldOf(key)
if (!item) return
const next = clamp(Math.round(span), 1, GRID)
if (next === item.span) return
if (!silentHistory) pushHistory()
item.span = next
renderAll()
}
/** 跨分组移动时把宽度换到目标组的列基准上(就近取整,避免出现半列宽的卡片) */
function snapToGroupCols(span, gid) {
const n = innerColsOf(gid)
return clamp(Math.round(span / colUnitOf(gid)), 1, n) * colUnitOf(gid)
}
/** 按「跨几列」设宽度:列数由所在分组的组内列数决定,结果必然落在列边界上 */
function setSpanByCols(key, cols) {
const item = fieldOf(key)
if (!item) return
const n = innerColsOf(item.group)
setSpan(key, clamp(Math.round(cols), 1, n) * colUnitOf(item.group))
}
/** −/+ :跨列数 ±1(最小单位就是 1 列,所以不需要更细的步进) */
function stepCols(key, delta) {
const item = fieldOf(key)
if (!item) return
setSpanByCols(key, colsOf(item) + (Math.sign(delta) || 1))
}
/** 占满本行:把自己加宽到填满当前行剩余空间(再贴回列边界) */
function fillLine(key) {
const item = fieldOf(key)
if (!item) return
const others = sum(
lineMembers(key).filter((f) => f.key !== key),
(f) => f.span,
)
setSpanByCols(key, (GRID - others) / colUnitOf(item.group))
}
/** 平分本行:显式动作,替代原来的「自动均分」;按列均分,余数列补给靠前的字段 */
function splitLine(key) {
const line = lineMembers(key)
if (line.length < 2) return
const item = fieldOf(key)
const n = innerColsOf(item.group)
const unit = colUnitOf(item.group)
pushHistory()
const each = Math.floor(n / line.length)
let rem = n - each * line.length
line.forEach((f) => {
const cols = clamp(each + (rem > 0 ? 1 : 0), 1, n)
if (rem > 0) rem--
f.span = cols * unit
})
renderAll()
}
function toggleFlag(key, prop) {
const item = fieldOf(key)
if (!item) return
pushHistory()
item[prop] = item[prop] ? 0 : 1
renderAll()
}
function removeField(key) {
const idx = state.fields.findIndex((f) => f.key === key)
if (idx < 0) return
pushHistory()
state.fields.splice(idx, 1)
if (state.selectedKey === key) state.selectedKey = null
renderAll()
}
function addField(label) {
const exist = state.fields.find((f) => f.label === label)
if (exist) {
selectField(exist.key, { scroll: true })
return
}
const gid = (state.selectedKey && fieldOf(state.selectedKey)?.group) || state.groups[0].id
pushHistory()
// 新字段默认占 1 列(列宽由目标分组的组内列数决定)
const item = { key: nextKey(), label, group: gid, span: colUnitOf(gid) }
state.fields.push(item)
state.selectedKey = item.key
renderAll()
scrollToField(item.key)
}
/** 移动字段:落到目标卡片前/后,或追加到某分组末尾;不触碰任何宽度 */
function moveField(key, drop) {
const from = state.fields.findIndex((f) => f.key === key)
if (from < 0) return
if (drop.key && !state.fields.some((f) => f.key === drop.key)) return
if (!drop.key && !drop.group) return
pushHistory()
const [item] = state.fields.splice(from, 1)
if (drop.key) {
const target = state.fields.findIndex((f) => f.key === drop.key)
const nextGroup = state.fields[target].group
if (nextGroup !== item.group) item.span = snapToGroupCols(item.span, nextGroup)
item.group = nextGroup
state.fields.splice(drop.pos === 'before' ? target : target + 1, 0, item)
} else if (drop.group) {
if (drop.group !== item.group) item.span = snapToGroupCols(item.span, drop.group)
item.group = drop.group
let last = -1
state.fields.forEach((f, i) => {
if (f.group === drop.group) last = i
})
state.fields.splice(last + 1, 0, item)
}
state.selectedKey = item.key
renderAll()
}
/** 分组宽度:一行并排几个分组(外层 48 格 → 48/份数) */
function setGroupWidth(gid, width) {
const group = groupOf(gid)
if (!group || group.width === width) return
pushHistory()
group.width = width
renderAll()
}
/**
* 组内列数:字段宽度的基准变了,按比例把每个字段换算到新基准上(只有用户显式改列数才发生)。
* 例:3 列组里的 8 格(占 1/3)→ 改成 6 列组后仍是 1/3,即 2 列 = 8 格。
*/
function setGroupInnerCols(gid, cols) {
const group = groupOf(gid)
if (!group || group.cols === cols) return
pushHistory()
const unit = GRID / cols
for (const f of groupFields(gid)) {
f.span = clamp(Math.round((f.span / GRID) * cols), 1, cols) * unit
}
group.cols = cols
renderAll()
}
function moveGroup(gid, dir) {
const idx = state.groups.findIndex((g) => g.id === gid)
const target = idx + dir
if (idx < 0 || target < 0 || target >= state.groups.length) return
pushHistory()
const [group] = state.groups.splice(idx, 1)
state.groups.splice(target, 0, group)
renderAll()
}
function selectField(key, { scroll = false } = {}) {
if (state.selectedKey === key) return
state.selectedKey = key
renderAll()
if (scroll && key) scrollToField(key)
}
function scrollToField(key) {
requestAnimationFrame(() => {
const el = document.querySelector(`.field[data-field="${key}"]`)
el?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' })
})
}
function setLibSearch(value) {
state.libSearch = value
renderLib()
}
/* ============================================================
* 6. 渲染
* ============================================================ */
function renderTopbar() {
$('#btn-undo').disabled = past.length === 0
$('#btn-redo').disabled = future.length === 0
$('#btn-guides').classList.toggle('is-on', state.showGuides)
}
function renderLib() {
const kw = state.libSearch.trim().toLowerCase()
const items = LIB.filter((label) => !kw || label.toLowerCase().includes(kw))
$('#lib-list').innerHTML = items
.map((label) => {
const added = state.fields.find((f) => f.label === label)
const selected = added && added.key === state.selectedKey
return `<button class="lib-item ${added ? 'is-added' : ''} ${
selected ? 'is-selected' : ''
}" data-lib="${escapeHtml(label)}">
<span>${escapeHtml(label)}</span>
${added ? '<span class="lib-item__flag">已加入</span>' : ''}
</button>`
})
.join('')
}
function renderCanvas() {
$('#canvas').innerHTML = state.groups
.map((group) => {
const width = GROUP_WIDTHS.includes(group.width) ? group.width : 1
const cols = innerColsOf(group.id)
const fields = groupFields(group.id)
const active =
state.showGuides && state.selectedKey && fieldOf(state.selectedKey)?.group === group.id
return `
<section class="group" data-group="${group.id}" style="grid-column: span ${
GROUP_GRID / width
}">
<header class="group__head">
<span class="group__title ${group.title ? '' : 'is-empty'}">${escapeHtml(
group.title || '未命名分组',
)}</span>
<div class="group__meta">
<span class="group__fill">${fillText(group.id)}</span>
<select class="group__cols" data-act="group-inner-cols" data-group="${
group.id
}" title="组内列数:字段宽度的基准(改后按比例换算)">
${GROUP_INNER_COLS.map(
(c) =>
`<option value="${c}" ${c === cols ? 'selected' : ''}>组内 ${c} 列</option>`,
).join('')}
</select>
<select class="group__cols" data-act="group-width" data-group="${
group.id
}" title="分组宽度:一行并排几个分组">
${GROUP_WIDTHS.map(
(w) =>
`<option value="${w}" ${w === width ? 'selected' : ''}>${FILL_LABELS[w]}</option>`,
).join('')}
</select>
<button class="icon-btn" data-act="group-move" data-group="${
group.id
}" data-dir="-1" title="上移分组">←</button>
<button class="icon-btn" data-act="group-move" data-group="${
group.id
}" data-dir="1" title="下移分组">→</button>
</div>
</header>
<div class="group-body ${active ? 'is-guides' : ''}" data-group="${group.id}">
${
active
? `<div class="guides" style="grid-template-columns: repeat(${cols}, minmax(0, 1fr))">${'<i></i>'.repeat(cols)}</div>`
: ''
}
${fields.map((f) => fieldHtml(f)).join('')}
${
fields.length
? ''
: '<div class="group-empty">拖字段到这里,或点左侧字段库加入</div>'
}
</div>
</section>`
})
.join('')
}
function fieldHtml(f) {
const selected = state.selectedKey === f.key
return `
<div class="field ${selected ? 'is-selected' : ''}" data-field="${f.key}" style="grid-column: span ${
f.span
}">
<div class="field__head">
<span class="field__name">${escapeHtml(f.label)}${
f.required ? '<i>*</i>' : ''
}</span>
<span class="field__chip" title="跨 ${colsOf(f)}/${innerColsOf(f.group)} 列(占 24 栅格的 ${f.span} 格)">${fmtSpan(f.span)}</span>
</div>
<div class="field__ctrl">占位提示</div>
<div class="field__resize" data-handle="right" title="拖动改宽(Alt 精调)"></div>
${selected ? toolbarHtml(f) : ''}
</div>`
}
function toolbarHtml(f) {
// 档位由该组的列数决定:3 列组给 1/3、2/3、整行;6 列组给 1/6 … 整行
const n = innerColsOf(f.group)
const unit = colUnitOf(f.group)
const current = colsOf(f)
const quick = Array.from({ length: n }, (_, i) => i + 1)
.map(
(k) =>
`<button class="tb-btn ${current === k ? 'is-active' : ''}" data-act="cols" data-cols="${k}" title="跨 ${k} 列(${k * unit}/24)">${
k >= n ? '整行' : fmtSpan(k * unit)
}</button>`,
)
.join('')
return `<div class="toolbar">
${quick}
<span class="tb-sep"></span>
<button class="tb-btn" data-act="step" data-delta="-1" title="跨列 −1">−</button>
<button class="tb-btn" data-act="step" data-delta="1" title="跨列 +1">+</button>
<span class="tb-sep"></span>
<button class="tb-btn" data-act="fill">占满本行</button>
<button class="tb-btn" data-act="split">平分本行</button>
<span class="tb-sep"></span>
<button class="tb-btn is-danger" data-act="remove">删除</button>
</div>`
}
function renderInspector() {
const box = $('#inspector')
const item = state.selectedKey ? fieldOf(state.selectedKey) : null
if (!item) {
box.innerHTML = `
<div class="panel-title">属性</div>
<div class="inspector__empty">
未选中字段。先点一张卡片,再试:
<ul>
<li>拖卡片<strong>右边缘</strong>改宽,按<strong>列</strong>吸附</li>
<li>工具条上点 <strong>2/3</strong>:1/3(8) → 16(3 列组)</li>
<li>把某组的<strong>组内列数</strong>改成 6 列,同一字段就能调到 4</li>
<li><kbd>Shift</kbd>+<kbd>←/→</kbd> 跨列 ±1</li>
<li><kbd>Shift</kbd>+<kbd>Alt</kbd>+<kbd>←/→</kbd> 自由 ±1 格</li>
<li>拖动卡片本身:换位置 / 换分组</li>
</ul>
</div>`
return
}
const line = lineMembers(item.key)
const used = sum(line, (f) => f.span)
const group = groupOf(item.group)
const n = innerColsOf(item.group)
const unit = colUnitOf(item.group)
const currentCols = colsOf(item)
box.innerHTML = `
<div class="panel-title">属性 <span>${escapeHtml(item.label)}</span></div>
<div class="prop">
<label>字段标题</label>
<div class="prop-static">${escapeHtml(item.label)}</div>
</div>
<div class="prop">
<label>宽度:跨 ${n} 列里的几列(${escapeHtml(
group.title || '未命名分组',
)},1 列 = ${unit} 格)</label>
<div class="scale" data-act="scale" style="grid-template-columns: repeat(${n}, minmax(0, 1fr))">${Array.from(
{ length: n },
(_, i) =>
`<span class="scale__cell ${i < currentCols ? 'is-on' : ''}" data-act="scale-set" data-cols="${
i + 1
}" title="跨 ${i + 1} 列(${(i + 1) * unit}/24)"></span>`,
).join('')}</div>
<div class="span-row">
<button class="btn" data-act="step" data-delta="-1" title="跨列 −1">−</button>
<button class="btn" data-act="step" data-delta="1" title="跨列 +1">+</button>
<input type="number" min="1" max="24" value="${item.span}" data-act="span-input" title="24 制格数(兜底输入)" />
<span class="span-hint">${currentCols}/${n} 列 · ${fmtSpan(item.span)} · ${item.span}/24</span>
</div>
<div class="span-row">
<span class="span-hint">本行 ${used}/24 · 剩 ${GRID - used}</span>
</div>
</div>
<div class="prop">
<label>所属分组</label>
<select data-act="field-group">
${state.groups
.map(
(g) =>
`<option value="${g.id}" ${g.id === item.group ? 'selected' : ''}>${escapeHtml(
g.title || '未命名分组',
)}</option>`,
)
.join('')}
</select>
</div>
<div class="prop">
<label>状态</label>
<div class="switch-line"><span>必填</span>
<input type="checkbox" data-act="flag" data-prop="required" ${
item.required ? 'checked' : ''
} /></div>
<div class="switch-line"><span>只读</span>
<input type="checkbox" data-act="flag" data-prop="readonly" ${
item.readonly ? 'checked' : ''
} /></div>
<div class="switch-line"><span>禁用</span>
<input type="checkbox" data-act="flag" data-prop="disabled" ${
item.disabled ? 'checked' : ''
} /></div>
</div>
<div class="split-actions">
<button class="btn" data-act="fill">占满本行</button>
<button class="btn" data-act="split">平分本行</button>
</div>
`
}
/** 工具条贴着画布右边界时右对齐:否则会被 .canvas-wrap 的 overflow 裁掉一截 */
function adjustToolbars() {
const wrap = document.querySelector('.canvas-wrap')
if (!wrap) return
const wrapRect = wrap.getBoundingClientRect()
for (const bar of wrap.querySelectorAll('.toolbar')) {
bar.classList.remove('is-flip')
if (bar.getBoundingClientRect().right > wrapRect.right - 6) bar.classList.add('is-flip')
}
}
function renderAll() {
renderTopbar()
renderLib()
renderCanvas()
renderInspector()
requestAnimationFrame(adjustToolbars)
}
/* ============================================================
* 7. 交互:拖右边缘改宽 / 拖动排序 / 点击选中
* ============================================================ */
const canvas = $('#canvas')
const bubble = $('#bubble')
const dropLine = $('#drop-line')
const dragLabel = $('#drag-label')
let resize = null // { key, cell, startX, startSpan, el, span }
let moveDrag = null // { key, x0, y0, active, drop }
function showBubble(text, x, y) {
bubble.innerHTML = text
bubble.style.display = 'block'
bubble.style.transform = `translate(${x + 14}px, ${y + 14}px)`
}
function hideBubble() {
bubble.style.display = 'none'
}
function startResize(event, card) {
const body = card.closest('.group-body')
const bodyRect = body.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
resize = {
key: card.dataset.field,
cell: bodyRect.width / GRID,
leftPx: cardRect.left - bodyRect.left,
startSpan: fieldOf(card.dataset.field).span,
startX: event.clientX,
el: card,
span: fieldOf(card.dataset.field).span,
}
card.querySelector('.field__resize').classList.add('is-active')
canvas.setPointerCapture(event.pointerId)
document.body.classList.add('is-dragging')
event.preventDefault()
}
function onResizeMove(event) {
const item = fieldOf(resize.key)
const unit = colUnitOf(item.group)
const n = innerColsOf(item.group)
const dx = event.clientX - resize.startX
const raw = resize.startSpan + dx / resize.cell
// 默认永远吸附到列边界(列模型的最小单位就是 1 列);按住 Alt 自由逐格精调
const span = event.altKey
? clamp(Math.round(raw), 1, GRID)
: clamp(Math.round(raw / unit), 1, n) * unit
const text = `<b>${fmtSpan(span)}</b> · 跨 ${Math.round(span / unit)}/${n} 列 · ${span}/24`
if (span === resize.span) {
showBubble(text, event.clientX, event.clientY)
return
}
resize.span = span
resize.el.style.gridColumn = `span ${span}`
const chip = resize.el.querySelector('.field__chip')
if (chip) chip.textContent = fmtSpan(span)
showBubble(text, event.clientX, event.clientY)
}
function endResize() {
const el = resize.el
const span = resize.span
const key = resize.key
const changed = span !== resize.startSpan
el.querySelector('.field__resize')?.classList.remove('is-active')
resize = null
hideBubble()
document.body.classList.remove('is-dragging')
if (changed) setSpan(key, span)
else renderAll()
}
function startMaybeMove(event, card) {
moveDrag = {
key: card.dataset.field,
x0: event.clientX,
y0: event.clientY,
active: false,
drop: null,
}
canvas.setPointerCapture(event.pointerId)
}
/** 落点推导:命中卡片左/右半边 → 插到前/后;落在分组空白 → 追加到该分组末尾 */
function computeDrop(x, y) {
for (const el of document.querySelectorAll('.field')) {
const rect = el.getBoundingClientRect()
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
if (el.dataset.field === moveDrag.key) return null
const pos = x < rect.left + rect.width / 2 ? 'before' : 'after'
return { key: el.dataset.field, pos, rect }
}
}
for (const body of document.querySelectorAll('.group-body')) {
const rect = body.getBoundingClientRect()
if (x >= rect.left - 20 && x <= rect.right + 20 && y >= rect.top - 24 && y <= rect.bottom + 24) {
return { group: body.dataset.group, rect }
}
}
return null
}
function updateDropLine(drop) {
for (const el of document.querySelectorAll('.group-body')) el.classList.remove('is-drop-target')
if (!drop) {
dropLine.style.display = 'none'
return
}
if (drop.key) {
const target = document.querySelector(`.field[data-field="${drop.key}"]`)
const rect = target.getBoundingClientRect()
dropLine.style.display = 'block'
dropLine.style.left = `${(drop.pos === 'before' ? rect.left : rect.right) - 1}px`
dropLine.style.top = `${rect.top - 2}px`
dropLine.style.height = `${rect.height + 4}px`
} else {
const body = document.querySelector(`.group-body[data-group="${drop.group}"]`)
body.classList.add('is-drop-target')
dropLine.style.display = 'none'
}
}
function onMoveMove(event) {
const drag = moveDrag
if (!drag.active) {
if (Math.abs(event.clientX - drag.x0) < 4 && Math.abs(event.clientY - drag.y0) < 4) return
drag.active = true
document.body.classList.add('is-dragging')
document.querySelector(`.field[data-field="${drag.key}"]`)?.classList.add('is-dragging')
dragLabel.textContent = fieldOf(drag.key)?.label || '字段'
dragLabel.style.display = 'block'
}
dragLabel.style.transform = `translate(${event.clientX + 12}px, ${event.clientY + 12}px)`
drag.drop = computeDrop(event.clientX, event.clientY)
updateDropLine(drag.drop)
}
function endMove() {
const drag = moveDrag
moveDrag = null
dragLabel.style.display = 'none'
document.body.classList.remove('is-dragging')
updateDropLine(null)
if (drag.active) {
if (drag.drop) moveField(drag.key, drag.drop)
else renderAll()
} else {
selectField(drag.key)
}
}
canvas.addEventListener('pointerdown', (event) => {
const handle = event.target.closest('[data-handle="right"]')
if (handle) {
startResize(event, handle.closest('.field'))
return
}
if (event.target.closest('[data-act]')) return // 工具条按钮走 click
const card = event.target.closest('.field')
if (card) startMaybeMove(event, card)
else if (!event.target.closest('.group')) selectField(null)
})
canvas.addEventListener('pointermove', (event) => {
if (resize) onResizeMove(event)
else if (moveDrag) onMoveMove(event)
})
canvas.addEventListener('pointerup', () => {
if (resize) endResize()
else if (moveDrag) endMove()
})
canvas.addEventListener('click', (event) => {
const action = event.target.closest('[data-act]')
if (action && action.tagName !== 'SELECT') {
onAction(action, event)
return
}
const card = event.target.closest('.field')
if (card) selectField(card.dataset.field)
})
canvas.addEventListener('change', (event) => {
const width = event.target.closest('[data-act="group-width"]')
if (width) {
setGroupWidth(width.dataset.group, Number(width.value))
return
}
const cols = event.target.closest('[data-act="group-inner-cols"]')
if (cols) setGroupInnerCols(cols.dataset.group, Number(cols.value))
})
function onAction(el, event) {
const key = el.closest('[data-field]')?.dataset.field || state.selectedKey
switch (el.dataset.act) {
case 'cols':
// 工具条档位:跨 k 列
setSpanByCols(key, Number(el.dataset.cols))
break
case 'step': {
// −/+ :跨列 ±1;按住 Alt 则自由 ±1 格(精调)
const delta = Number(el.dataset.delta)
const span = fieldOf(key)?.span ?? GRID
if (event.altKey) setSpan(key, span + Math.sign(delta))
else stepCols(key, delta)
break
}
case 'fill':
fillLine(key)
break
case 'split':
splitLine(key)
break
case 'remove':
removeField(key)
break
case 'group-move':
moveGroup(el.dataset.group, Number(el.dataset.dir))
break
default:
break
}
}
/* ============================================================
* 8. 属性面板与字段库
* ============================================================ */
$('#inspector').addEventListener('click', (event) => {
const action = event.target.closest('[data-act]')
if (!action) return
if (action.dataset.act === 'scale-set') {
if (state.selectedKey) setSpanByCols(state.selectedKey, Number(action.dataset.cols))
return
}
onAction(action, event)
})
// 数字框走 change(失焦/回车):输入过程中不重建面板,避免焦点被抢走
$('#inspector').addEventListener('change', (event) => {
const el = event.target.closest('[data-act]')
if (!el || !state.selectedKey) return
if (el.dataset.act === 'span-input') {
setSpan(state.selectedKey, Number(el.value) || 1)
} else if (el.dataset.act === 'field-group') {
const item = fieldOf(state.selectedKey)
if (!item || item.group === el.value) return
pushHistory()
if (el.value !== item.group) item.span = snapToGroupCols(item.span, el.value)
item.group = el.value
renderAll()
} else if (el.dataset.act === 'flag') {
const item = fieldOf(state.selectedKey)
if (!item) return
pushHistory()
item[el.dataset.prop] = el.checked ? 1 : 0
renderAll()
}
})
$('#lib-list').addEventListener('click', (event) => {
const item = event.target.closest('[data-lib]')
if (item) addField(item.dataset.lib)
})
$('#lib-search').addEventListener('input', (event) => setLibSearch(event.target.value))
$('#btn-undo').addEventListener('click', undo)
$('#btn-redo').addEventListener('click', redo)
$('#btn-guides').addEventListener('click', () => {
state.showGuides = !state.showGuides
renderAll()
})
/* ============================================================
* 9. 键盘
* ============================================================ */
window.addEventListener('keydown', (event) => {
const mod = event.ctrlKey || event.metaKey
if (mod && (event.key === 'z' || event.key === 'Z')) {
event.preventDefault()
if (event.shiftKey) redo()
else undo()
return
}
if (mod && (event.key === 'y' || event.key === 'Y')) {
event.preventDefault()
redo()
return
}
const key = state.selectedKey
if (!key || event.target.matches('input, select, textarea')) return
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault()
removeField(key)
return
}
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
const dir = event.key === 'ArrowRight' ? 1 : -1
if (!event.shiftKey) return
event.preventDefault()
// Shift = 跨列 ±1;Shift+Alt = 自由 ±1 格(精调)
if (event.altKey) setSpan(key, fieldOf(key).span + dir)
else stepCols(key, dir)
}
})
renderAll()
</script>
</body>
</html>