/**
|
* 「绑定数据」属性控件
|
* <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);
|
}
|
},
|
});
|
}
|