1634 lines
53 KiB
HTML
1634 lines
53 KiB
HTML
<!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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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>
|