11 小时以前 35722562e9e13f0504acc15b740d042ecb810199
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
/**
 * 「绑定数据」属性控件
 * <p>
 * 声明了 bindable 的字段,属性面板除了能手工输入,还可以从组件 dataSchema 里挑一个数据字段。
 * 挑中后写进属性值的仍是普通文本,渲染引擎按绑定协议解析,
 * 所以绑定与手工输入走的是同一条路径,不存在两套取值逻辑。
 */
import type { Component, Editor, Trait } from 'grapesjs';
 
import type { QualityFieldSchema } from './types';
 
import { propAttrName } from './attrs';
import { definitionOf } from './lookup';
 
/** 绑定控件在 GrapesJS 里注册的类型名 */
export const BINDING_TRAIT_TYPE = 'qc-binding';
 
/** 不绑定时的空选项 */
const NO_BINDING_LABEL = '不绑定(手工输入)';
 
const SELECT_CLASS = 'qc-binding-select';
const INPUT_CLASS = 'qc-binding-input';
const HINT_CLASS = 'qc-binding-hint';
 
/** 绑定值写回属性的形式,与 QualityFieldSchema.bindAs 对应 */
type BindMode = 'path' | 'text';
 
/** 绑定路径 → 属性值;text 包成 {{path}} 交渲染期解析,path 必须保持裸路径 */
function toBindingValue(path: string, mode: BindMode): string {
  if (!path) {
    return '';
  }
  return mode === 'path' ? path : `{{${path}}}`;
}
 
/** 属性值 → 绑定路径;识别不出就返回空串,表示这是手工输入而非绑定 */
function fromBindingValue(value: string, mode: BindMode): string {
  const trimmed = value.trim();
  if (mode === 'path') {
    return trimmed;
  }
  const match = /^\{\{\s*([^{}]+?)\s*\}\}$/.exec(trimmed);
  return match?.[1]?.trim() ?? '';
}
 
interface BindingInputParts {
  input: HTMLInputElement;
  select: HTMLSelectElement;
}
 
/**
 * 自定义 trait 回调的入参。
 * <p>
 * TraitManager.addType 的泛型不会把参数类型推导到回调上,不显式标注就会是隐式 any。
 * 形状与 TraitView 内部 getClbOpts() 的返回值一致。
 */
interface TraitViewOpts {
  component: Component;
  elInput: HTMLInputElement;
  trait: Trait;
}
 
/** 找到当前 trait 对应的属性字段,取它的绑定形式与提示 */
function fieldOf(component: Component | undefined, trait: Trait): QualityFieldSchema | undefined {
  const name = trait.getName();
  return definitionOf(component)?.propertySchema.find((field) => propAttrName(field.key) === name);
}
 
function bindModeOf(component: Component | undefined, trait: Trait): BindMode {
  return fieldOf(component, trait)?.bindAs === 'path' ? 'path' : 'text';
}
 
/** 在属性面板里建出「下拉选择 + 手工输入 + 提示」三件套 */
function createBindingInput(trait: Trait, component?: Component): HTMLElement {
  const definition = definitionOf(component);
  const wrap = document.createElement('div');
  wrap.className = 'qc-binding';
 
  const select = document.createElement('select');
  select.className = SELECT_CLASS;
  select.append(new Option(NO_BINDING_LABEL, ''));
  (definition?.dataSchema ?? []).forEach((field) => {
    // 选项值就是绑定路径,标签给用户看懂用途
    select.append(new Option(field.label, field.key));
  });
 
  const input = document.createElement('input');
  input.type = 'text';
  input.className = INPUT_CLASS;
  input.placeholder = trait.get('placeholder') ?? '{{report.batchNo}}';
 
  wrap.append(select, input);
 
  const tip = fieldOf(component, trait)?.tip;
  if (tip) {
    const hint = document.createElement('div');
    hint.className = HINT_CLASS;
    hint.textContent = tip;
    wrap.append(hint);
  }
  return wrap;
}
 
function partsOf(elInput: HTMLElement): BindingInputParts | undefined {
  const select = elInput.querySelector<HTMLSelectElement>(`.${SELECT_CLASS}`);
  const input = elInput.querySelector<HTMLInputElement>(`.${INPUT_CLASS}`);
  if (!select || !input) {
    return undefined;
  }
  return { input, select };
}
 
/** 把当前属性值回填到两个控件上:能识别成绑定的勾下拉,否则只填输入框 */
function syncFromTrait(elInput: HTMLElement, trait: Trait, component?: Component): void {
  const parts = partsOf(elInput);
  if (!parts) {
    return;
  }
  const value = String(trait.getValue() ?? '');
  const path = fromBindingValue(value, bindModeOf(component, trait));
  // 设置了 value 却不在候选项里(如组件换过、路径已废弃)时补一个临时选项,避免下拉显示空白
  if (path && ![...parts.select.options].some((option) => option.value === path)) {
    parts.select.append(new Option(path, path));
  }
  parts.select.value = path;
  parts.input.value = value;
}
 
/** 注册绑定控件类型;重复注册直接覆盖,配合热更新不报错 */
export function registerBindingTraitType(editor: Editor): void {
  editor.TraitManager.addType(BINDING_TRAIT_TYPE, {
    createInput({ trait, component }) {
      return createBindingInput(trait, component);
    },
    onRender({ elInput, trait, component }: TraitViewOpts) {
      syncFromTrait(elInput, trait, component);
    },
    onUpdate({ elInput, trait, component }: TraitViewOpts) {
      syncFromTrait(elInput, trait, component);
    },
    onEvent({ elInput, trait, component, event }: TraitViewOpts & { event: Event }) {
      const target = event.target;
      if (!(target instanceof HTMLElement)) {
        return;
      }
      const parts = partsOf(elInput);
      if (!parts) {
        return;
      }
      if (target.classList.contains(SELECT_CLASS)) {
        const value = toBindingValue(parts.select.value, bindModeOf(component, trait));
        parts.input.value = value;
        trait.setValue(value);
        return;
      }
      if (target.classList.contains(INPUT_CLASS)) {
        trait.setValue(parts.input.value);
      }
    },
  });
}