// ==UserScript== // @name 酷家乐公式编辑器重制 // @namespace tm.kujiale.formula // @version 1.0.0 // @description 按VSCode风格重写酷家乐参数化编辑器「公式编辑器」弹窗:左侧函数树+中间字段面板+右侧高亮代码编辑器,支持格式化、Ctrl+/ 行注释、函数文档、字段自动识别(#参数 / @shelf)。 // @match https://www.kujiale.com/vc/modeleditor/new* // @grant none // @run-at document-idle // @require https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.min.js // @require https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/edit/matchbrackets.min.js // @require https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/mode/overlay.min.js // @require https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/selection/active-line.min.js // @require https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/fold/foldcode.min.js // @require https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/fold/foldgutter.min.js // ==/UserScript== (function () { 'use strict'; const TMFE_VERSION = "1.0.0"; // 会显示在窗口标题后,便于确认安装的版本 const TMFE_RAINBOW_N = 5; // 彩虹括号/缩进线循环色数(与 CSS 中 tmfe-rb-N / tmfe-ig-N 一一对应) const CM = window.CodeMirror; if (!CM) { console.error('[TMFE] CodeMirror 加载失败'); return; } /* ================= 数据定义 ================= */ const FUNC_GROUPS = [ { name: '逻辑', items: ['AND', 'OR', '&&', '||', '?:'] }, { name: '比较', items: ['==', '!='] }, { name: '算术', items: ['+', '-', '*', '/', '^', '%'] }, { name: '数学', items: ['#abs()', '#ceil()', '#floor()', '#round()', '#deRound()', '#sqrt()', '#min()', '#max()', '#average()', '#statusSum()', '#float2Int()', '#sin()', '#cos()', '#tan()', '#asin()', '#acos()', '#atan()', '#toRadians()', '#toDegrees()', '#rad()'] }, { name: '文本', items: ['#left()', '#right()', '#mid()', '#strToNum()', '#firstIndexOf()', '#lastIndexOf()', '#strContains()'] }, { name: '判断', items: ['#isValue()', '#isNull()', '#boolAt()', '#boolAll()'] }, { name: '属性', items: ['#getProductCustomAttr()', '#getSelfAttr()', '#getSelfCustomAttr()', '#getOptionName()'] }, ]; // 函数名(去掉括号)集合,用于高亮 const FUNC_NAMES = new Set(); FUNC_GROUPS.forEach(g => g.items.forEach(it => { if (it.startsWith('#')) FUNC_NAMES.add(it.replace(/\(\)$/, '')); })); const BARE_OPS = new Set(['AND', 'OR']); // 商品(材质/造型/部件类参数)的系统字段:#CZ. 这类二级补全用。 // 字段表与酷家乐一致:基础 5 字段 + window.PARAM_MODEL_TOOL.brandGoodPropertyHints 企业定制扩展。 const SYSTEM_FIELD_LABELS = { name: '商品名称', model: '模型', productcode: '产品编码', customcode: '自定义编码', bgId: '商品id', versionId: '版本id', scriptDataResourceId: '脚本资源id', customTexture: '自定义贴图', baseTexture: '基础贴图', w: '贴图宽度', d: '贴图长度', suppressed: '抑制条件', position: '位置', rotationDegree: '旋转', ignore: '隐藏条件', replaceable: '可替换', isDeletable: '可删除', paramOverride: '参数可编辑', displayInCostList: '清单输出', needQuotation: '需要报价', invokedPosType: '调用方式', }; function brandGoodFieldsFor(valueType) { // 仅商品类参数(材质/造型/部件)有二级;数值/布尔等返回 null if (valueType && ['material', 'shape', 'style'].indexOf(valueType) < 0) return null; const base = ['name', 'model', 'productcode', 'customcode', 'bgId']; const styleExtra = ['versionId', 'scriptDataResourceId', 'functionName']; let hints = {}; try { hints = (window.PARAM_MODEL_TOOL && window.PARAM_MODEL_TOOL.brandGoodPropertyHints) || {}; } catch (e) {} let fields; if (valueType === 'style') fields = base.concat(styleExtra, hints.style || []); else if (valueType === 'shape') fields = base.concat(hints.shape || []); else fields = base.concat(hints.material || []); // material 或类型未知时兜底 const seen = {}; const out = []; fields.forEach(function (f) { if (seen[f]) return; seen[f] = 1; out.push({ value: f, info: SYSTEM_FIELD_LABELS[f] || f }); }); return out; } // 变量元数据(来自 /editor/api/site/editordata),Ctrl+点击弹窗用 const VAR_META = {}; const VT_MAP = { float: '浮点数', float2: '二维向量', float3: '三维向量', int: '整数', string: '字符串', boolean: '布尔', material: '材质', fit: '适配', }; const DOCS = { 'AND': { t: '和', s: '表达式1 AND 表达式2', d: '检查如果全部表达式为真(TRUE),则返回真(TRUE);如有一个表达式为假(FALSE),则返回假(FALSE)。', e: '#W >= 200 AND #D <= 500 AND #H > 1200' }, 'OR': { t: '或', s: '表达式1 OR 表达式2', d: '只要有一个表达式为真(TRUE)即返回真(TRUE);全部为假(FALSE)时才返回假(FALSE)。', e: '#W > 500 OR #H > 1200' }, '&&': { t: '逻辑与(同 AND)', s: '表达式1 && 表达式2', d: '与 AND 等价的逻辑与运算。', e: '#W > 100 && #D > 100' }, '||': { t: '逻辑或(同 OR)', s: '表达式1 || 表达式2', d: '与 OR 等价的逻辑或运算。', e: '#W > 500 || #H > 1200' }, '?:': { t: '三元条件', s: '条件 ? 值1 : 值2', d: '条件为真(TRUE)返回值1,否则返回值2。', e: '#W > 900 ? 900 : #W' }, '==': { t: '等于', s: '表达式1 == 表达式2', d: '两个表达式相等时返回真(TRUE)。', e: '#DJ == 30' }, '!=': { t: '不等于', s: '表达式1 != 表达式2', d: '两个表达式不相等时返回真(TRUE)。', e: '#DJ != 0' }, '+': { t: '加', s: '表达式1 + 表达式2', d: '求和。', e: '#W + #D' }, '-': { t: '减', s: '表达式1 - 表达式2', d: '求差。', e: '#W - #D' }, '*': { t: '乘', s: '表达式1 * 表达式2', d: '求积。', e: '#W * #D' }, '/': { t: '除', s: '表达式1 / 表达式2', d: '求商。', e: '#W / 2' }, '^': { t: '乘方', s: '表达式1 ^ 表达式2', d: '求幂。', e: '#W ^ 2' }, '%': { t: '取模', s: '表达式1 % 表达式2', d: '求余数。', e: '#W % 2' }, '#abs()': { t: '绝对值', s: '#abs(数值)', d: '返回数值的绝对值。', e: '#abs(-5) → 5' }, '#ceil()': { t: '向上取整', s: '#ceil(数值)', d: '返回大于等于该数值的最小整数。', e: '#ceil(1.2) → 2' }, '#floor()': { t: '向下取整', s: '#floor(数值)', d: '返回小于等于该数值的最大整数。', e: '#floor(1.8) → 1' }, '#round()': { t: '四舍五入', s: '#round(数值)', d: '四舍五入取整。', e: '#round(1.5) → 2' }, '#deRound()': { t: '按位四舍五入', s: '#deRound(数值, 位数)', d: '按指定小数位数四舍五入。', e: '#deRound(3.14159, 2) → 3.14' }, '#sqrt()': { t: '平方根', s: '#sqrt(数值)', d: '返回数值的平方根。', e: '#sqrt(9) → 3' }, '#min()': { t: '最小值', s: '#min(数值1, 数值2, ...)', d: '返回所有数值中的最小值。', e: '#min(#W, #D, #H)' }, '#max()': { t: '最大值', s: '#max(数值1, 数值2, ...)', d: '返回所有数值中的最大值。', e: '#max(#W, #D, #H)' }, '#average()': { t: '平均值', s: '#average(数值1, 数值2, ...)', d: '返回所有数值的平均值。', e: '#average(#W, #D)' }, '#statusSum()': { t: '状态求和', s: '#statusSum(...)', d: '对枚举/状态值求和。', e: '#statusSum(#KC1, #KC3)' }, '#float2Int()': { t: '浮点转整数', s: '#float2Int(数值)', d: '浮点数转整数(舍去小数部分)。', e: '#float2Int(3.9) → 3' }, '#sin()': { t: '正弦', s: '#sin(弧度)', d: '返回正弦值,参数为弧度。', e: '#sin(#rad(30))' }, '#cos()': { t: '余弦', s: '#cos(弧度)', d: '返回余弦值,参数为弧度。', e: '#cos(#rad(60))' }, '#tan()': { t: '正切', s: '#tan(弧度)', d: '返回正切值,参数为弧度。', e: '#tan(#rad(45))' }, '#asin()': { t: '反正弦', s: '#asin(数值)', d: '返回反正弦值(弧度)。', e: '#asin(0.5)' }, '#acos()': { t: '反余弦', s: '#acos(数值)', d: '返回反余弦值(弧度)。', e: '#acos(0.5)' }, '#atan()': { t: '反正切', s: '#atan(数值)', d: '返回反正切值(弧度)。', e: '#atan(1)' }, '#toRadians()': { t: '角度转弧度', s: '#toRadians(角度)', d: '将角度转换为弧度。', e: '#toRadians(180)' }, '#toDegrees()': { t: '弧度转角度', s: '#toDegrees(弧度)', d: '将弧度转换为角度。', e: '#toDegrees(3.14159)' }, '#rad()': { t: '角度转弧度', s: '#rad(角度)', d: '将角度转换为弧度(同 #toRadians)。', e: '#rad(30)' }, '#left()': { t: '取左侧字符', s: '#left(字符串, 个数)', d: '从字符串左侧起取指定个数字符。', e: '#left("ABCDEF", 2) → "AB"' }, '#right()': { t: '取右侧字符', s: '#right(字符串, 个数)', d: '从字符串右侧起取指定个数字符。', e: '#right("ABCDEF", 2) → "EF"' }, '#mid()': { t: '取中间字符', s: '#mid(字符串, 起始位置, 个数)', d: '从起始位置起取指定个数字符。', e: '#mid("ABCDEF", 2, 3) → "BCD"' }, '#strToNum()': { t: '字符串转数字', s: '#strToNum(字符串)', d: '将字符串转换为数字。', e: '#strToNum("12.5") → 12.5' }, '#firstIndexOf()': { t: '首次出现位置', s: '#firstIndexOf(字符串, 子串)', d: '返回子串首次出现的位置(未找到返回 -1)。', e: '#firstIndexOf("ABCBA", "B") → 1' }, '#lastIndexOf()': { t: '最后出现位置', s: '#lastIndexOf(字符串, 子串)', d: '返回子串最后出现的位置。', e: '#lastIndexOf("ABCBA", "B") → 3' }, '#strContains()': { t: '包含子串', s: '#strContains(字符串, 子串)', d: '判断字符串是否包含子串,包含返回真(TRUE)。', e: '#strContains("ABC", "BC")' }, '#isValue()': { t: '是否有值', s: '#isValue(表达式)', d: '表达式有有效值(非空)时返回真(TRUE)。', e: '#isValue(#SGBZ)' }, '#isNull()': { t: '是否为空', s: '#isNull(表达式)', d: '表达式为空时返回真(TRUE)。', e: '#isNull(#SGBZ)' }, '#boolAt()': { t: '取布尔值', s: '#boolAt(..., 位置)', d: '取指定位置处的布尔值。', e: '#boolAt(#X, 1)' }, '#boolAll()': { t: '全部为真', s: '#boolAll(...)', d: '所有布尔值都为真(TRUE)才返回真(TRUE)。', e: '#boolAll(#A, #B)' }, '#getProductCustomAttr()': { t: '取产品自定义属性', s: '#getProductCustomAttr(属性名)', d: '获取产品的自定义属性值。', e: '#getProductCustomAttr("颜色")' }, '#getSelfAttr()': { t: '取自身属性', s: '#getSelfAttr(属性名)', d: '获取当前对象自身的属性值。', e: '#getSelfAttr("名称")' }, '#getSelfCustomAttr()': { t: '取自身自定义属性', s: '#getSelfCustomAttr(属性名)', d: '获取当前对象自身的自定义属性值。', e: '#getSelfCustomAttr("厚度")' }, '#getOptionName()': { t: '取选项名称', s: '#getOptionName(选项)', d: '获取选项的名称文本。', e: '#getOptionName(#DJ)' }, }; /* ================= 样式 ================= */ const CSS = ` .tmfe-root{display:flex;flex-direction:column;height:100%;min-height:420px;background:#fff; border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;font-size:13px;color:#374151; font-family:'Sarasa Mono SC','Microsoft YaHei',sans-serif} .tmfe-toolbar{display:flex;align-items:center;gap:8px;padding:8px 10px;background:#f9fafb; border-bottom:1px solid #e5e7eb} .tmfe-toolbar .tmfe-hint{color:#9ca3af;font-size:12px;width:364px;flex:none} .tmfe-vsep{width:1px;align-self:stretch;background:#e5e7eb;flex:none} .tmfe-calcbtn{border:1px solid #d1d5db;background:#fff;color:#374151;border-radius:4px; padding:2px 10px;cursor:pointer;font-size:12px;line-height:1.6} .tmfe-calcbtn:hover{border-color:#1d4ed8;color:#1d4ed8} .tmfe-push{margin-left:auto} .tmfe-body{display:flex;flex:1;min-height:0} .tmfe-left{width:200px;flex:none;display:flex;flex-direction:column;background:#fafafa;border-right:1px solid #e5e7eb} .tmfe-mid{width:180px;flex:none;display:flex;flex-direction:column;background:#fafafa;border-right:1px solid #e5e7eb;position:relative} /* 函数栏收起/展开按钮:钉在中栏左缘(红框位置),收起后 200px 让给公式编辑器 */ .tmfe-func-toggle{position:absolute;left:-10px;top:5px;width:20px;height:20px;padding:0; border:1px solid #e5e7eb;border-radius:4px;background:#fff;color:#6b7280;cursor:pointer; font-size:11px;line-height:17px;text-align:center;z-index:20} .tmfe-func-toggle:hover{color:#2563eb;border-color:#bfdbfe;background:#eff6ff} .tmfe-root.tmfe-func-collapsed .tmfe-left{display:none} /* # / @ 实时补全弹窗(对齐原生检索框) */ .tmfe-cmplete{position:absolute;z-index:60;min-width:220px;max-width:360px;max-height:280px; overflow:auto;background:#fff;border:1px solid #e5e7eb;border-radius:6px; box-shadow:0 8px 24px rgba(0,0,0,.12);font-size:12px;display:none;padding:2px 0} .tmfe-cmplete-item{display:flex;justify-content:space-between;align-items:center;gap:12px; padding:5px 12px;cursor:pointer;color:#111827} .tmfe-cmplete-item .nm{font-weight:500} .tmfe-cmplete-item .lbl{color:#9ca3af;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .tmfe-cmplete-item.tmfe-item-sel{background:#eff6ff;color:#1d4ed8} .tmfe-evalpop{position:absolute;z-index:70;background:#fff;border:1px solid #e5e7eb;border-radius:6px; box-shadow:0 8px 24px rgba(0,0,0,.12);font-size:12px;padding:4px 6px;display:none} .tmfe-evalbtn{border:none;background:#1d4ed8;color:#fff;border-radius:4px;padding:3px 10px;cursor:pointer;font-size:12px} .tmfe-evalbtn:hover{background:#1e40af} .tmfe-evalbtn[disabled]{opacity:.6;cursor:default} .tmfe-eval-result{padding:3px 6px;color:#111827;white-space:nowrap} .tmfe-right{flex:1;min-width:0;display:flex;flex-direction:column} .tmfe-panel-title{padding:6px 10px;font-size:12px;color:#6b7280;border-bottom:1px solid #eceef1; background:#f4f5f7;user-select:none} .tmfe-func-tree{flex:1;overflow:auto;padding:4px 0} .tmfe-func-group{margin-top:4px} .tmfe-func-group-name{display:flex;align-items:center;gap:6px;padding:8px 10px 2px; color:#9ca3af;font-size:11px;user-select:none} .tmfe-func-group-name .line{flex:1;height:1px;background:#e5e7eb} .tmfe-func-item{padding:3px 10px 3px 18px;cursor:pointer;white-space:nowrap;overflow:hidden; text-overflow:ellipsis;font-family:'Sarasa Mono SC',Consolas,monospace} .tmfe-func-item:hover{background:#eef2ff} .tmfe-func-item.selected,.tmfe-func-item.cursor-active{background:#e0eaff;color:#1d4ed8} .tmfe-func-doc{flex:none;max-height:180px;overflow:auto;border-top:1px solid #e5e7eb; padding:8px 10px;background:#fff} .tmfe-func-doc .t{font-weight:600;color:#111827;margin:0 0 4px} .tmfe-func-doc .head{color:#9ca3af;font-size:11px;margin:6px 0 2px} .tmfe-func-doc .desc,.tmfe-func-doc .syn,.tmfe-func-doc .ex{margin:0;color:#374151; font-family:'Sarasa Mono SC',Consolas,monospace;word-break:break-all} .tmfe-field-search{padding:6px 8px;border-bottom:1px solid #eceef1} .tmfe-field-search input{width:100%;box-sizing:border-box;padding:4px 6px;border:1px solid #d1d5db; border-radius:4px;font-size:12px;outline:none;font-family:inherit} .tmfe-field-search input:focus{border-color:#93b4fd} .tmfe-field-list{flex:1;overflow:auto;padding:4px 0} .tmfe-field-group{margin-top:2px} .tmfe-field-group-name{display:flex;align-items:center;gap:6px;padding:8px 10px 2px; color:#9ca3af;font-size:11px;user-select:none} .tmfe-field-group-name .line{flex:1;height:1px;background:#e5e7eb} .tmfe-field-item{padding:3px 10px;cursor:pointer;font-family:'Sarasa Mono SC',Consolas,monospace; white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .tmfe-field-item .fname{display:inline-block;text-align:right;color:#9ca3af; margin-right:6px;font-family:'Microsoft YaHei',sans-serif} .tmfe-field-item .fref{font-family:'Sarasa Mono SC',Consolas,monospace} /* Ctrl+悬停参数下划线 */ .tmfe-ctrl-link{text-decoration:underline;cursor:pointer} /* 解析图选中节点时联动高亮的公式行 */ .tmfe-parse-hl{background-color:#e8f0fe} /* 参数详情弹窗 */ .tmfe-right{position:relative} .tmfe-param-pop{position:absolute;top:8px;right:8px;width:220px;background:#fff; border:1px solid #e5e7eb;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,.12); z-index:20;font-size:12px;overflow:hidden} .tmfe-param-pop-head{display:flex;align-items:center;justify-content:space-between; padding:6px 10px;background:#f4f5f7;border-bottom:1px solid #eceef1; font-family:'Sarasa Mono SC',Consolas,monospace;font-weight:600;color:#2563eb} .tmfe-param-pop-close{border:none;background:none;cursor:pointer;font-size:14px; color:#6b7280;line-height:1;padding:0 2px} .tmfe-param-pop-close:hover{color:#111827} .tmfe-param-pop-body{padding:8px 10px;max-height:260px;overflow:auto} .tmfe-param-pop-body p.row{display:flex;margin:4px 0;color:#374151;word-break:break-all} .tmfe-param-pop-body .k{flex:none;width:66px;text-align:right;color:#9ca3af;margin-right:6px} .tmfe-param-pop-body .v{flex:1;white-space:pre-wrap} .tmfe-param-pop-body .sec{display:flex;align-items:center;gap:6px;margin:8px 0 2px} .tmfe-param-pop-body .sec .line{flex:1;height:1px;background:#e5e7eb} .tmfe-param-pop-body .sec span{color:#9ca3af;font-size:11px} .tmfe-field-item:hover,.tmfe-field-item.cursor-active{background:#eef2ff} .tmfe-field-item.is-func{color:#7c3aed} .tmfe-field-item.is-special{color:#ea580c} .tmfe-field-empty{padding:10px;color:#9ca3af;font-size:12px} .tmfe-editor-host{flex:1;min-height:0} .tmfe-editor-host .CodeMirror{height:100%;font-family:'Sarasa Mono SC',Consolas,monospace; font-size:13px;line-height:1.65;background:#fff} .tmfe-result{color:#6b7280;font-size:12px;white-space:nowrap;overflow:hidden; text-overflow:ellipsis;max-width:420px} /* 窗口标题后的版本号(原底部状态条已移除) */ .tmfe-title-ver{margin-left:8px;color:#9ca3af;font-size:11px;font-weight:400;user-select:none} /* CodeMirror 高亮配色 */ .CodeMirror .cm-variable-2{color:#2563eb} .CodeMirror .cm-builtin{color:#7c3aed} .CodeMirror .cm-keyword{color:#ea580c;font-weight:600} .CodeMirror .cm-string{color:#d97706} .CodeMirror .cm-number{color:#16a34a} .CodeMirror .cm-comment{color:#9ca3af;font-style:italic} .CodeMirror .cm-operator{color:#0f172a;font-weight:600} /* 括号自动匹配高亮(matchbrackets addon 只挂类,这里给醒目样式) */ .CodeMirror-matchingbracket{background:#fde68a !important;color:#b45309 !important;font-weight:700;border-radius:2px} /* 彩虹括号:按嵌套深度吃 5 色高饱和彩虹(红→橙→黄→绿→蓝 循环)。 markText 方案类名不带 cm- 前缀,两条选择器都覆盖以保险 */ .CodeMirror span.tmfe-rb-0,.CodeMirror .cm-tmfe-rb-0{color:#e53935;font-weight:700} .CodeMirror span.tmfe-rb-1,.CodeMirror .cm-tmfe-rb-1{color:#fb8c00;font-weight:700} .CodeMirror span.tmfe-rb-2,.CodeMirror .cm-tmfe-rb-2{color:#c6a700;font-weight:700} .CodeMirror span.tmfe-rb-3,.CodeMirror .cm-tmfe-rb-3{color:#43a047;font-weight:700} .CodeMirror span.tmfe-rb-4,.CodeMirror .cm-tmfe-rb-4{color:#1e88e5;font-weight:700} /* 彩虹缩进色块层(VSCode indentRainbow 样式):插在 .CodeMirror-lines 文字层后面, 每行每级一个色块 —— 背景即缩进空格的彩虹底色,左边框 1px 即该级竖线(连续不断) */ .tmfe-ig-layer{position:absolute;left:0;top:0;right:0;bottom:0;pointer-events:none;overflow:hidden} .CodeMirror .tmfe-ig-layer div{position:absolute} .CodeMirror .tmfe-ig-lv0{background:rgba(229,57,53,.09);border-left:1px solid rgba(229,57,53,.55)} .CodeMirror .tmfe-ig-lv1{background:rgba(251,140,0,.09);border-left:1px solid rgba(251,140,0,.55)} .CodeMirror .tmfe-ig-lv2{background:rgba(198,167,0,.09);border-left:1px solid rgba(198,167,0,.55)} .CodeMirror .tmfe-ig-lv3{background:rgba(67,160,71,.09);border-left:1px solid rgba(67,160,71,.55)} .CodeMirror .tmfe-ig-lv4{background:rgba(30,136,229,.09);border-left:1px solid rgba(30,136,229,.55)} /* 当前行高亮(styleActiveLine):去掉默认背景色,改为上/下两条 1px 边框线 (inset box-shadow 不占布局;两条边框比整框更贴合 VSCode 当前行的观感) */ .CodeMirror .CodeMirror-activeline-background{background:transparent !important} .CodeMirror .CodeMirror-activeline{box-shadow:inset 0 1px 0 rgba(59,130,246,.4), inset 0 -1px 0 rgba(59,130,246,.4)} /* 行号:VSCode 风格浅灰小字 */ .CodeMirror .CodeMirror-linenumber{color:#b0b7c3;font-size:11px;padding-right:6px} /* 代码折叠:VSCode 风格细箭头(▸ 折叠 / ⌄ 展开),默认浅灰、悬停加深 */ .CodeMirror .CodeMirror-foldgutter{width:14px} .CodeMirror .CodeMirror-foldgutter-open:after, .CodeMirror .CodeMirror-foldgutter-folded:after{ content:"";display:block;width:0;height:0;margin:1px 1px 1px 3px; border:4px solid transparent;cursor:pointer; } .CodeMirror .CodeMirror-foldgutter-open:after{border-top-color:#b0b7c3;border-bottom-width:2px} /* ⌄ */ .CodeMirror .CodeMirror-foldgutter-folded:after{border-left-color:#b0b7c3;border-top-width:3px;border-bottom-width:3px} /* ▸ */ .CodeMirror-gutter:hover .CodeMirror-foldgutter-open:after{border-top-color:#6b7280} .CodeMirror-gutter:hover .CodeMirror-foldgutter-folded:after{border-left-color:#6b7280} /* 折叠后正文里的省略标记 */ .CodeMirror .CodeMirror-foldmarker{color:#2563eb;text-shadow:none;font-family:inherit;font-size:12px;cursor:pointer;background:#eef2ff;border-radius:3px;padding:0 4px;margin:0 2px} /* ============ 展开公式解析:弹窗尺寸不变,解析图停靠在编辑器右侧 ============ */ /* 图区宽度:解析面板吃掉「树+字段列(≈383px)+公式列(400px)」以外的全部宽度; 公式列收窄到红框标注的 ~400px(块级 auto 宽 = 容器 − 左列 − margin) */ .tmfe-splitmode{--tmfe-diag-w:max(420px, calc(100% - 785px))} .tmfe-splitmode > .tmfe-root .tmfe-right{margin-right:var(--tmfe-diag-w)} .tmfe-diag-pane{position:absolute !important;top:45px;bottom:45px;right:0;left:auto; width:var(--tmfe-diag-w) !important;height:auto !important; background:#fff;border-left:1px solid #e5e7eb; overflow-x:hidden;overflow-y:scroll;scrollbar-gutter:stable;z-index:30} .tmfe-diag-pane > .parse-ast-modal{width:100%;height:100%} /* 防滚动条抖动:解析面板(本身即原生 .tui-splitter-content)在「滚动条出现→内容 变窄→x6 重排→滚动条消失」间形成反馈循环。固定 overflow-y 并预留 gutter, 宽度恒定后循环即断;x6 容器同样锁 gutter 兜底 */ .tmfe-diag-pane .x6-graph-scroller{scrollbar-gutter:stable} /* 拖宽手柄:贴在解析面板左缘,按住左右拖动调节图区宽度(写入 --tmfe-diag-w 并记忆) */ .tmfe-diag-pane .tmfe-diag-resizer{position:absolute;left:-4px;top:0;bottom:0;width:8px; cursor:col-resize;z-index:50;background:transparent;touch-action:none} .tmfe-diag-pane .tmfe-diag-resizer:hover,.tmfe-diag-pane .tmfe-diag-resizer.tmfe-dragging{background:rgba(37,99,235,.18)} `; function injectCss() { if (document.getElementById('tmfe-style')) return; const st = document.createElement('style'); st.id = 'tmfe-style'; st.textContent = CSS; document.head.appendChild(st); const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = 'https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.min.css'; document.head.appendChild(link); } /* ================= 语法高亮 mode ================= */ CM.defineMode('spellite', function () { return { token: function (stream) { if (stream.eatSpace()) return null; if (stream.match('//')) { stream.skipToEnd(); return 'comment'; } if (stream.match(/"(?:[^"\\]|\\.)*"?/)) return 'string'; if (stream.match(/\d+(\.\d+)?/)) return 'number'; let m = stream.match(/@[A-Za-z0-9_\u4e00-\u9fa5]+/); if (m) return 'keyword'; // @shelf 等特殊公式 m = stream.match(/#[A-Za-z0-9_\u4e00-\u9fa5]+/); if (m) return FUNC_NAMES.has(stream.current()) ? 'builtin' : 'variable-2'; if (stream.match(/&&|\|\||==|!=/)) return 'operator'; m = stream.match(/^[A-Za-z_\u4e00-\u9fa5]+/); if (m) { const t = stream.current(); if (BARE_OPS.has(t)) return 'operator'; if (FUNC_NAMES.has(t)) return 'builtin'; return null; } if (stream.match(/^[+\-*/^%?:]/)) return 'operator'; stream.next(); return null; }, }; }); /* ================= 词法 / 格式化 ================= */ function tmTokenize(src) { const toks = []; let i = 0; // 兜底保险:任何分支若不推进 i 就会死循环卡死页面,这里硬性限制迭代次数 const maxIter = Math.max(64, src.length * 4); let iter = 0; while (i < src.length) { if (++iter > maxIter) break; const c = src[i]; if (c === '/' && src[i + 1] === '/') { let j = src.indexOf('\n', i); if (j < 0) j = src.length; toks.push({ t: 'comment', v: src.slice(i, j) }); i = j; continue; } if (/\s/.test(c)) { i++; continue; } if (c === '"') { let j = i + 1; while (j < src.length && src[j] !== '"') { if (src[j] === '\\') j++; j++; } toks.push({ t: 'string', v: src.slice(i, Math.min(j + 1, src.length)) }); i = j + 1; continue; } if (/[0-9]/.test(c) || (c === '.' && /[0-9]/.test(src[i + 1] || ''))) { let j = i; while (j < src.length && /[0-9.]/.test(src[j])) j++; toks.push({ t: 'number', v: src.slice(i, j) }); i = j; continue; } if (/[#@A-Za-z_\u4e00-\u9fa5]/.test(c)) { // 注意:起始字符本身可能是 # 或 @(不在后续字符集里),必须从 i+1 开始扫描, // 否则 j 永远等于 i,会产生空 token 且不推进 i → 死循环 let j = i + 1; while (j < src.length && /[A-Za-z0-9_\u4e00-\u9fa5]/.test(src[j])) j++; toks.push({ t: 'ident', v: src.slice(i, j) }); i = j; continue; } if (c === '(' || c === ')') { toks.push({ t: c, v: c }); i++; continue; } if (c === ',') { toks.push({ t: ',', v: ',' }); i++; continue; } const two = src.slice(i, i + 2); if (two === '&&' || two === '||' || two === '==' || two === '!=') { toks.push({ t: 'op', v: two }); i += 2; continue; } if ('+-*/^%?'.includes(c)) { toks.push({ t: 'op', v: c }); i++; continue; } toks.push({ t: 'other', v: c }); i++; } return toks; } function tmParse(toks) { let i = 0; function parseCall() { // "(" 已消费 const open = toks[i - 1]; // 开括号 token,其上可能挂着前导注释 const args = []; let cur = []; while (i < toks.length) { const tk = toks[i]; if (tk.t === ')') { i++; break; } if (tk.t === ',') { args.push(cur); cur = []; i++; continue; } // parseCall() 返回的已经是 {call: args} 节点,不要再包一层 { call: ... } if (tk.t === '(') { i++; cur.push(parseCall()); continue; } cur.push(tk); i++; } args.push(cur); const node = { call: args }; if (open && open.lead) node.lead = open.lead; // 注释跟着组走,别弄丢 return node; } const nodes = []; while (i < toks.length) { const tk = toks[i]; if (tk.t === '(') { i++; nodes.push(parseCall()); continue; } nodes.push(tk); i++; } return nodes; } // 函数调用始终单行输出(格式化只针对三元表达式) function renderInline(nodes) { let s = ''; for (const n of nodes) { if (n.call) { const txt = '(' + n.call.map(a => renderInline(a)).join(', ') + ')'; // 只有紧跟在标识符后面才紧贴(如 #max(),否则补空格(如 ? (、+ ( ) if (s !== '' && !/[A-Za-z0-9_#\u4e00-\u9fa5]/.test(s.slice(-1))) s += ' '; s += txt; continue; } const v = n.v; if (s === '') { s = v; continue; } const prev = s.slice(-1); let need = true; if (prev === '(') need = false; if (v === ')' || v === ',') need = false; // 三元 ':' 两侧都留空格 if (v === '(' && /[A-Za-z0-9_#\u4e00-\u9fa5]/.test(prev)) need = false; if (v === '.' || prev === '.') need = false; // 属性访问 #LSKS.name 不拆开 if (need) s += ' '; s += v; } return s; } // 三元是右结合的:a ? b ? c : d : e 等价于 a ? (b ? c : d) : e。 // 用栈配对:遇到 ? 入栈,遇到 : 弹出最近的 ? 与之配对, // 最外层就是第一个 ?,它的 : 由栈配对结果决定(不能简单取「第一个冒号」)。 function splitTernary(nodes) { const stack = []; const pair = {}; let qi = -1; for (let k = 0; k < nodes.length; k++) { const n = nodes[k]; if (n.call) continue; if (n.t === 'op' && n.v === '?') { stack.push(k); if (qi < 0) qi = k; } else if (n.v === ':') { const q = stack.pop(); if (q !== undefined && pair[q] === undefined) pair[q] = k; } } const ci = qi >= 0 ? pair[qi] : -1; return { cond: nodes.slice(0, qi), then: qi >= 0 ? nodes.slice(qi + 1, ci === undefined || ci < 0 ? nodes.length : ci) : [], els: ci !== undefined && ci >= 0 ? nodes.slice(ci + 1) : [], }; } function hasTernary(nodes) { return nodes.some(n => !n.call && n.t === 'op' && n.v === '?'); } // 括号里也可能藏着三元,判断嵌套时要往里看 function hasTernaryDeep(nodes) { return nodes.some(n => n.call ? n.call.some(a => hasTernaryDeep(a)) : (n.t === 'op' && n.v === '?')); } // 是否需要把三元拆成多行: // 嵌套在别的三元里 → 一定拆(保持链式每一层都换行); // 分支里还藏着三元 → 拆;否则超过 60 字符才拆 function shouldBreakTernary(nodes, nested) { if (!hasTernary(nodes)) return false; if (nested) return true; const t = splitTernary(nodes); if (hasTernaryDeep(t.cond) || hasTernaryDeep(t.then) || hasTernaryDeep(t.els)) return true; return renderInline(nodes).length > 60; } function appendToLast(lines, suffix, ind) { if (!lines.length) lines.push({ ind: ind, text: suffix.trim() }); else lines[lines.length - 1].text += suffix; } // 段首注释:单独占一行,缩进与本段首行一致。 // 取走后就清空,避免「父段 + cond 子段」共用同一个首节点时注释被输出两次 function leadLines(nodes, ind) { if (!nodes.length || !nodes[0].lead) return []; const lead = nodes[0].lead; nodes[0].lead = null; return lead.map(c => ({ ind: ind, text: c })); } // 括号组里是否藏着三元(含嵌套更深层) function groupHasTernary(n) { return !!n.call && n.call.some(a => hasTernaryDeep(a)); } // 把节点序列按「含三元的括号组」切段:段间是普通内联 token。 // 这是「括号内有三元 → 整组拆多行」的关键:括号组此前被 tmParse 收拢成 // {call} 节点,三元配对和换行逻辑都看不见它,导致括号里的三元永远不换行 function splitGroups(nodes) { const parts = []; let seg = []; for (const n of nodes) { if (groupHasTernary(n)) { if (seg.length) parts.push({ seg: seg }); parts.push({ grp: n }); seg = []; } else { seg.push(n); } } if (seg.length) parts.push({ seg: seg }); return parts; } // 把一个含三元的括号组渲染进 lines: // ( // body...(缩进一级) // ) // 函数名后的 ( 紧贴(#max();连接符(+ / ? 等)后的 ( 独立成行(用户指定) function renderGroupInto(lines, node, ind, nested) { if (node.lead) { // 组前的注释先落行,再开括号 node.lead.forEach(function (c) { lines.push({ ind: ind, text: c }); }); node.lead = null; } const last = lines.length ? lines[lines.length - 1] : null; if (last && /[A-Za-z0-9_#\u4e00-\u9fa5(]$/.test(last.text)) { last.text += '('; // 函数调用(如 #max()括号紧贴 } else { lines.push({ ind: ind, text: '(' }); } const args = node.call; args.forEach(function (a, ai) { const al = renderLines(a, ind + 1, nested); if (!al.length) return; if (ai < args.length - 1) appendToLast(al, ',', ind + 1); al.forEach(function (l) { lines.push(l); }); }); lines.push({ ind: ind, text: ')' }); } // 按段渲染:含三元的括号组拆块,普通段走三元阶梯 / 内联。 // afterQ:上一段以 '?' 结尾时,紧随其后的括号组整块再缩进一级(用户指定); // ':' 开头的段是三元 else:':' 接在上一行(组的 ')')行尾,其后的值换行, // 且与前面的括号组同级缩进(':' 前后的 then/else 槽位同级,用户指定)。 function renderPartsLines(parts, ind, nested) { const lines = []; let afterQ = false; let lastGrpInd = null; parts.forEach(function (p) { if (p.grp) { lastGrpInd = afterQ ? ind + 1 : ind; renderGroupInto(lines, p.grp, lastGrpInd, nested); afterQ = false; return; } if (lines.length && p.seg.length && p.seg[0].v === ':') { appendToLast(lines, ' :', ind); const restInd = lastGrpInd !== null ? lastGrpInd : ind; renderLines(p.seg.slice(1), restInd, nested).forEach(function (l) { lines.push(l); }); afterQ = false; return; } const segLines = renderLines(p.seg, ind, nested); if (!segLines.length) return; if (!lines.length) { segLines.forEach(function (l) { lines.push(l); }); } else { // 段首行接在上一行尾(如 `) +`、`) ?`),其余行另起 appendToLast(lines, ' ' + segLines[0].text.trim(), ind); for (let k = 1; k < segLines.length; k++) lines.push(segLines[k]); } afterQ = / \?$/.test(lines[lines.length - 1].text); }); return lines; } // 三元表达式渲染成若干行:条件顶格、true / else 分支都在下一级缩进上, // 链式三元每嵌套一层整体右移一级,形成阶梯式缩进 function renderExprLines(nodes, ind, nested) { if (!nodes.length) return []; // 有含三元的括号组 → 整组拆多行(括号 ( 独占/接上一行,body 缩进一级,) 单独一行) const parts = splitGroups(nodes); if (parts.length > 1 || parts[0].grp) return renderPartsLines(parts, ind, nested); if (!shouldBreakTernary(nodes, nested)) return [{ ind: ind, text: renderInline(nodes) }]; const t = splitTernary(nodes); // 条件顶格;true 分支缩进一级;else 分支(无论是否链式三元)都在同一级缩进上, // 链式每嵌套一层整体再往右一级,形成阶梯式缩进 const cond = renderLines(t.cond, ind, false); appendToLast(cond, ' ?', ind); const then = renderLines(t.then, ind + 1, true); appendToLast(then, ' :', ind + 1); const els = renderLines(t.els, ind + 1, true); return cond.concat(then, els); } function renderLines(nodes, ind, nested) { return leadLines(nodes, ind).concat(renderExprLines(nodes, ind, nested)); } // 把 // 注释从 token 流里摘出来,挂到它后面的那个 token 上(lead), // 这样三元配对不受注释干扰,注释也能跟着所属分支落在正确缩进上 function attachComments(toks) { const out = []; let pending = []; toks.forEach(function (t) { if (t.t === 'comment') { pending.push(t.v); return; } if (pending.length) { t.lead = pending; pending = []; } out.push(t); }); return { toks: out, trailing: pending }; } function fmtFormula(src) { try { return fmtFormulaUnsafe(src); } catch (e) { console.error('[TMFE] 格式化失败,已跳过:', e); return src; } } function fmtFormulaUnsafe(src) { const raw = tmTokenize(src); if (!raw.some(t => t.t === 'op' && t.v === '?')) return src; // 没有三元就不动 const attached = attachComments(raw); const nodes = tmParse(attached.toks); const lines = renderLines(nodes, 0); // 末尾没有后继代码的注释,跟上一行同缩进 (attached.trailing || []).forEach(function (c) { lines.push({ ind: lines.length ? lines[lines.length - 1].ind : 0, text: c }); }); return lines.map(l => ' '.repeat(l.ind) + l.text.trim()).join('\n').trim(); } /* ================= Ctrl+/ 行注释 ================= */ function toggleLineComment(cm) { const from = cm.getCursor('from'), to = cm.getCursor('to'); const lines = []; for (let l = from.line; l <= to.line; l++) lines.push(l); const allCommented = lines.every(l => /^\s*\/\//.test(cm.getLine(l))); cm.operation(function () { lines.forEach(function (l) { const s = cm.getLine(l); if (allCommented) { const m = s.match(/^(\s*)\/\/(.*)$/); if (m) cm.replaceRange(m[1] + m[2], { line: l, ch: 0 }, { line: l, ch: s.length }); } else { const m = s.match(/^(\s*)/); const ind = m[1]; cm.replaceRange(ind + '//' + s.slice(ind.length), { line: l, ch: 0 }, { line: l, ch: s.length }); } }); }); } /* ================= 弹窗改写 ================= */ let cmInst = null; // 代码折叠范围(行号折叠标记用): // ① 括号 `(`:仅「分组括号」可折——前一非空白字符是标识符的(函数调用 #max()不折; // ② 三元 `?`:折到配对的 `:`(跨行;括号内嵌套三元自带 ?/: 平衡,栈计数天然正确)。 // 起点必须在 from 行上,终点可以跨行;同行整体(起止同行)不折。 function tmfeFoldRange(cm, from) { const doc = cm.getValue(); const lineStarts = []; let off = 0; doc.split('\n').forEach(function (l) { lineStarts.push(off); off += l.length + 1; }); const lineEnd = from.line + 1 < lineStarts.length ? lineStarts[from.line + 1] - 1 : doc.length; // 在 from 行内找第一个可折叠起点 for (let i = lineStarts[from.line]; i < lineEnd; i++) { const c = doc[i]; if (c === '"') { // 跳过字符串 i++; while (i < lineEnd && doc[i] !== '"') { if (doc[i] === '\\') i++; i++; } continue; } if (c === '(') { let j = i - 1; while (j >= lineStarts[from.line] - 4 && j >= 0 && /[ \t]/.test(doc[j])) j--; // 函数调用(#max()不折叠;注意行首时看上一行尾字符的场景不存在(标识符必同行) if (j >= 0 && /[A-Za-z0-9_#\u4e00-\u9fa5]/.test(doc[j])) continue; // 找匹配的 ) let d = 0, k = i, end = -1; for (; k < doc.length; k++) { const cc = doc[k]; if (cc === '"') { k++; while (k < doc.length && doc[k] !== '"') { if (doc[k] === '\\') k++; k++; } continue; } if (cc === '(') d++; else if (cc === ')') { d--; if (d === 0) { end = k; break; } } } if (end < 0 || end === i) return; let sl = from.line, sc = -1, el = end, c2 = -1; // idx → (line, ch) let sIdx = i, eIdx = end; let lo = from.line; for (let L = from.line; L < lineStarts.length; L++) { if (lineStarts[L] <= sIdx) lo = L; else break; } sl = lo; sc = sIdx - lineStarts[lo]; let lo2 = from.line; for (let L = from.line; L < lineStarts.length; L++) { if (lineStarts[L] <= eIdx) lo2 = L; else break; } el = lo2; c2 = eIdx - lineStarts[lo2]; if (sl === el) return; return { from: CodeMirror.Pos(sl, sc), to: CodeMirror.Pos(el, c2 + 1) }; } if (c === '?') { // 三元 ? 到配对 : let stack = 0, k = i + 1, end = -1; for (; k < doc.length; k++) { const cc = doc[k]; if (cc === '"') { k++; while (k < doc.length && doc[k] !== '"') { if (doc[k] === '\\') k++; k++; } continue; } if (cc === '?') stack++; else if (cc === ':') { if (stack === 0) { end = k; break; } stack--; } } if (end < 0) return; let lo = from.line, lo2 = from.line; for (let L = from.line; L < lineStarts.length; L++) { if (lineStarts[L] <= i) lo = L; else break; } for (let L = from.line; L < lineStarts.length; L++) { if (lineStarts[L] <= end) lo2 = L; else break; } if (lo === lo2) return; return { from: CodeMirror.Pos(lo, i - lineStarts[lo]), to: CodeMirror.Pos(lo2, end - lineStarts[lo2] + 1) }; } } return; } function getOrigText(scope) { const lines = scope.querySelectorAll('.cm-content .cm-line'); if (lines.length) return Array.from(lines).map(l => l.textContent).join('\n'); const c = scope.querySelector('.cm-content'); return c ? c.textContent : ''; } function writeBack(scope, text) { const content = scope.querySelector('.cm-content'); if (!content) return; // CM6:EditorView 挂在 .cm-content 的 cmView.view 上; // .cm-editor 元素上没有 cmEditor/cmView(旧路径取不到,回写失效) let view = (content.cmView && content.cmView.view) || null; if (!view) { const editorEl = content.closest('.cm-editor'); view = editorEl && (editorEl.cmEditor || (editorEl.cmView && editorEl.cmView.view)) || null; } if (view && view.state && view.dispatch) { view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } }); return; } // 兜底:display:none 下 focus/execCommand 会静默失败。 // 把原生内容区临时移到屏幕外(保持渲染、非 display:none),写入后立即还原。 const host = content.closest('.content-wrap') || editorEl || content; const saved = host.style.cssText; try { host.style.cssText = saved + ';position:fixed!important;left:-99999px!important;top:0!important;width:600px;height:200px;overflow:hidden;z-index:-1;visibility:visible!important;opacity:0;pointer-events:none;'; void host.offsetHeight; // 强制 reflow,确保样式生效 content.focus(); const sel = window.getSelection(); const range = document.createRange(); range.selectNodeContents(content); sel.removeAllRanges(); sel.addRange(range); document.execCommand('insertText', false, text); } finally { host.style.cssText = saved; void host.offsetHeight; } } function escapeHtml(s) { return s.replace(/&/g, '&').replace(//g, '>'); } function buildUI(content) { const dialog = content.closest('.tui-dialog'); if (dialog) dialog.style.width = '980px'; // 原生自带 margin:28px,会在弹窗四周留出四条白边,全部去掉 content.style.margin = '0'; // 原生规则 .formula-editor-dialog-content-wrap{width:calc(100% - var(--dialog_padding)*2)} // 是为原生内边距让位的——白边移除后这条让位变成右侧 56px 空白,改为撑满 content.style.width = '100%'; // 原生两块区域(编辑器 + 函数说明)由 syncToolbarButtons 周期性隐藏—— // 它们的 DOM 路径随 React 渲染状态变化,不能依赖固定路径一次性查询 // 记录弹窗原始几何(展开公式解析时用于锁定尺寸,避免弹窗放大/还原来回跳变) const tuiDialog = content.closest('.tui-dialog'); const dialogBody = content.closest('.tui-dialog-body'); // 弹窗组件会复用同一 DOM:先清掉上一轮会话遗留的尺寸锁与分屏痕迹 content.classList.remove('tmfe-splitmode'); content.style.removeProperty('height'); if (dialogBody) dialogBody.style.overflow = ''; if (tuiDialog && tuiDialog.style.getPropertyPriority('width')) { tuiDialog.style.removeProperty('width'); } if (tuiDialog) tuiDialog.style.width = '980px'; content.querySelectorAll('.tmfe-diag-pane').forEach(function (p) { p.classList.remove('tmfe-diag-pane'); }); const geom = { bodyEl: dialogBody, bodyOverflow: dialogBody ? dialogBody.style.overflow : '', }; // 窗口高度 = 页面高 - 100px(上下各留 50px): // 内容区高度 = 100vh - 100px - 弹窗自身 chrome(头部 + 页脚) const dr = tuiDialog.getBoundingClientRect(); const br = dialogBody.getBoundingClientRect(); const chrome = Math.round((br.top - dr.top) + (dr.bottom - br.bottom)) || 112; content.style.height = 'calc(100vh - ' + (100 + chrome) + 'px)'; // 原生按钮懒渲染,周期性补克隆(按 label 去重);ui 转为绝对定位(分屏态)后无需再补 const geomSampler = setInterval(function () { if (!content.isConnected) { clearInterval(geomSampler); return; } if (!content.classList.contains('tmfe-splitmode')) { syncToolbarButtons(); } }, 400); const origText = getOrigText(content); const root = document.createElement('div'); root.className = 'tmfe-root'; // 工具栏:克隆原版「计算结果 / 展开公式解析」按钮。 // 注意:不能直接搬移 React 渲染的按钮节点——React 重渲染后节点会脱离其事件委托树, // 变成点击无响应的死按钮。改为克隆外观,点击时转发给内容区里原生的隐藏按钮。 const bar = document.createElement('div'); bar.className = 'tmfe-toolbar'; // 布局(左→右,对齐原生截图):提示文字 → 「计算」按钮 → 计算结果 → (弹性空隙)→ 原生按钮克隆件 const hint = document.createElement('span'); hint.className = 'tmfe-hint'; hint.textContent = 'Ctrl+/ 注释 · 双击函数/参数插入 · 按住 Ctrl 点击参数看详情'; bar.appendChild(hint); // 竖分隔线:与下方「参数」栏/编辑区的分栏线对齐(左栏200+中栏180=380,扣除内边距差) const vsep = document.createElement('span'); vsep.className = 'tmfe-vsep'; bar.appendChild(vsep); // 「计算」= 原生「计算结果」按钮的转发件(原生按钮由 sync 克隆时跳过,避免重复) const calcBtn = document.createElement('button'); calcBtn.type = 'button'; calcBtn.className = 'tmfe-calcbtn'; calcBtn.textContent = '计算'; calcBtn.title = '计算当前公式(转发原生计算结果)'; calcBtn.addEventListener('click', function () { // 先把当前编辑器内容同步回原编辑器,保证计算基于最新内容 writeBack(content, cmInst ? cmInst.getValue() : origText); let target = null; content.querySelectorAll('.result-trigger').forEach(function (b) { if (!b.closest('.tmfe-root') && b.textContent.trim() === '计算结果') target = b; }); if (target) target.click(); // React 会重渲染原生按钮,每次实时查找 }); bar.appendChild(calcBtn); const res = document.createElement('span'); res.className = 'tmfe-result'; res.textContent = ''; bar.appendChild(res); // 弹窗刚打开时原生区域/按钮可能尚未渲染,且 DOM 路径随 React 渲染状态变化, // 此函数可重入:每次全局实时查询、隐藏原生区域、只补充缺失的克隆件 function syncToolbarButtons() { content.querySelectorAll('.function-helper-wrap').forEach(function (h) { h.style.display = 'none'; }); content.querySelectorAll('.content-wrap').forEach(function (cw) { if (!cw.closest('.function-helper-wrap')) cw.style.display = 'none'; }); const labels = Array.from(bar.querySelectorAll('.result-trigger')).map(function (b) { return b.textContent.trim(); }); content.querySelectorAll('.result-trigger').forEach(function (btn) { if (btn.closest('.tmfe-root')) return; // 排除工具栏里的克隆件 // 只克隆 React 渲染的原生按钮;无 React 标记的是历史克隆件(注入残留),跳过 const isReactNode = Object.keys(btn).some(function (k) { return k.indexOf('__reactInternal') === 0 || k.indexOf('__reactFiber') === 0; }); if (!isReactNode) return; const label = btn.textContent.trim(); if (label === '计算结果') return; // 已由工具栏自建「计算」按钮替代,避免重复 if (labels.indexOf(label) !== -1) return; const clone = btn.cloneNode(true); clone.classList.add('tmfe-push'); // 克隆按钮推到工具栏最右 clone.addEventListener('click', function () { // 先把当前公式同步回原编辑器,保证计算/解析基于最新内容 writeBack(content, cmInst ? cmInst.getValue() : origText); // React 会重渲染原生按钮,每次实时查找,且必须排除工具栏里的克隆件 let target = null; content.querySelectorAll('.result-trigger').forEach(function (b) { if (!b.closest('.tmfe-root') && b.textContent.trim() === label) target = b; }); if (target) target.click(); }); bar.appendChild(clone); labels.push(label); }); } syncToolbarButtons(); root.appendChild(bar); // 三栏主体 const body = document.createElement('div'); body.className = 'tmfe-body'; // 左:函数树 + 文档 const left = document.createElement('div'); left.className = 'tmfe-left'; let treeHtml = '
▾ 条件、运算符和函数
'; FUNC_GROUPS.forEach(function (g) { treeHtml += '
' + '
' + escapeHtml(g.name) + '
'; g.items.forEach(function (it) { treeHtml += '
' + escapeHtml(it) + '
'; }); treeHtml += '
'; }); treeHtml += '
' + '

公式编辑器

点击函数查看说明,双击插入到编辑器。

'; left.innerHTML = treeHtml; body.appendChild(left); // 中:字段面板 const mid = document.createElement('div'); mid.className = 'tmfe-mid'; mid.innerHTML = '
▾ 参数
' + '' + '
'; body.appendChild(mid); // 「条件、运算符和函数」栏收起/展开按钮:钉在中栏左缘;收起后 200px 由 // flex:1 的编辑器列自动吸收。状态记入 localStorage,下次打开弹窗保持 const funcToggle = document.createElement('button'); funcToggle.type = 'button'; funcToggle.className = 'tmfe-func-toggle'; mid.appendChild(funcToggle); function applyFuncCollapsed(collapsed) { root.classList.toggle('tmfe-func-collapsed', collapsed); funcToggle.textContent = collapsed ? '»' : '«'; funcToggle.title = collapsed ? '展开函数栏' : '收起函数栏'; try { localStorage.setItem('tmfeFuncCollapsed', collapsed ? '1' : '0'); } catch (_) { /* 忽略 */ } if (cmInst) cmInst.refresh(); } funcToggle.addEventListener('click', function () { applyFuncCollapsed(!root.classList.contains('tmfe-func-collapsed')); }); let funcCollapsed0 = false; try { funcCollapsed0 = localStorage.getItem('tmfeFuncCollapsed') === '1'; } catch (_) { /* 忽略 */ } applyFuncCollapsed(funcCollapsed0); // 右:编辑器 const right = document.createElement('div'); right.className = 'tmfe-right'; const host = document.createElement('div'); host.className = 'tmfe-editor-host'; right.appendChild(host); body.appendChild(right); // 版本号放到窗口标题「公式编辑器」后面(原底部状态条移除) const titleEl = document.querySelector('.tui-dialog-title > div'); if (titleEl) { const old = titleEl.querySelector('.tmfe-title-ver'); if (old) old.remove(); const ver = document.createElement('span'); ver.className = 'tmfe-title-ver'; ver.textContent = 'v' + TMFE_VERSION; titleEl.appendChild(ver); } // 参数详情弹窗(保留原生 Ctrl+点击参数查看详情的能力) const pop = document.createElement('div'); pop.className = 'tmfe-param-pop'; pop.style.display = 'none'; pop.innerHTML = '
' + '
' + '
'; right.appendChild(pop); pop.querySelector('.tmfe-param-pop-close').addEventListener('click', function () { pop.style.display = 'none'; }); function popRow(f) { return '

' + escapeHtml(f[0]) + '' + '' + escapeHtml(f[1]) + '

'; } function popSection(label) { return '
' + (label ? '' + escapeHtml(label) + '' : '') + '
'; } // ---- 商品资源解析:把详情弹窗的「当前值」「锁定条件」解析成真实数据 ---- // 页面上存在 props 同时挂有 getGoodsResource(ResourceType) 与 customPackageResource 的 React 组件, // ResourceType 为字符串枚举:'model' / 'material' / 'shape',资源形如 { items: { [id]: {...} } }。 let resProvider = null; function findResProvider() { const els = document.querySelectorAll('*'); for (let i = 0; i < els.length; i++) { const el = els[i]; const fk = Object.keys(el).find(k => k.indexOf('__reactInternalInstance') === 0); if (!fk) continue; let up = el[fk], n = 0; while (up && n < 80) { const p = up.memoizedProps; if (p && typeof p.getGoodsResource === 'function' && p.customPackageResource) { return { goods: p.getGoodsResource, cust: p.customPackageResource }; } up = up.return; n++; } } return null; } // style 参数的 value 是 JSON({"obsBrandGoodId":"xx","versionId":0}),取 obsBrandGoodId 查 model 资源; // material / shape 的 value 本身就是资源 ID,直接查对应资源。 function resolveGoodsName(valueType, value) { if (!value) return null; if (!resProvider) resProvider = findResProvider(); if (!resProvider) return null; let id = value; if (valueType === 'style') { try { id = JSON.parse(value).obsBrandGoodId; } catch (e) { return null; } } if (!id) return null; const type = valueType === 'material' ? 'material' : (valueType === 'shape' ? 'shape' : 'model'); try { const res = resProvider.goods(type); const item = res && res.items && res.items[id]; if (item && !item.deleted) return item.name || item.itemName || null; } catch (e) { /* 资源缺失时降级显示原始值 */ } return null; } // 锁定条件:linkForm 为空时 link 是自定义包 ID → customPackageResource 里查名称; // linkForm === 'condition' 时 link 是条件 JSON,取其 defaultValue。 function resolveLinkName(link, linkForm) { if (!link) return null; if (linkForm === 'condition') { try { const j = JSON.parse(link); if (j && j.defaultValue !== undefined) return String(j.defaultValue); } catch (e) { /* 降级 */ } return null; } if (!resProvider) resProvider = findResProvider(); if (resProvider && resProvider.cust && resProvider.cust.items) { const ci = resProvider.cust.items[link]; if (ci && ci.name && !ci.deleted) return ci.name; } return null; } function showParamPopup(ref) { const meta = VAR_META[ref] || {}; const fallback = PAGE_VARS.find(v => v.ref === ref) || {}; const rec = Array.isArray(meta.editorRecommends) && meta.editorRecommends.length ? meta.editorRecommends.join(', ') : (meta.step || ''); const unit = meta.valueFormat === undefined ? '—' : (meta.valueFormat === 0 ? '默认' : String(meta.valueFormat)); // 当前值/锁定条件优先显示解析后的真实数据,解析失败回退原始值 let curVal = meta.value || ''; const gName = resolveGoodsName(meta.valueType, meta.value); if (gName) curVal = gName; let lockVal = meta.link || ''; const lName = resolveLinkName(meta.link, meta.linkForm); if (lName) lockVal = lName; // 与原生 .param-detail-wrap 字段一一对应 const rows1 = [ ['名称:', meta.displayName || fallback.name || ''], ['引用名:', ref], ['参数类型:', VT_MAP[meta.valueType] || meta.valueType || ''], ['隐藏条件:', meta.ignore || ''], ]; const rows2 = [ ['最小值:', meta.min || ''], ['最大值:', meta.max || ''], ['推荐值:', rec], ['当前值:', curVal], ]; const rows3 = [ ['单位类型:', unit], ['锁定条件:', lockVal], ['分级标签:', ''], ['描述:', meta.description || ''], ]; pop.querySelector('.t').textContent = '#' + ref; pop.querySelector('.tmfe-param-pop-body').innerHTML = rows1.map(popRow).join('') + popSection('区间') + rows2.map(popRow).join('') + popSection('') + rows3.map(popRow).join(''); pop.style.display = 'block'; } // Ctrl+悬停下划线 + Ctrl+点击弹详情 let hoverMark = null; function clearHover() { if (hoverMark) { hoverMark.clear(); hoverMark = null; } host.style.cursor = ''; } function paramAt(pos) { if (!cmInst) return null; const token = cmInst.getTokenAt(pos, true); if (token.type === 'variable-2' && /^#[A-Za-z0-9_\u4e00-\u9fa5]+$/.test(token.string)) { return { ref: token.string.slice(1), from: { line: pos.line, ch: token.start }, to: { line: pos.line, ch: token.end } }; } return null; } host.addEventListener('mousemove', function (e) { if (!e.ctrlKey || !cmInst) { clearHover(); return; } const pos = cmInst.coordsChar({ left: e.clientX, top: e.clientY }); const hit = paramAt(pos); if (!hit) { clearHover(); return; } if (hoverMark) { const range = hoverMark.find(); if (range && range.from.ch === hit.from.ch && range.to.ch === hit.to.ch && range.from.line === hit.from.line) return; clearHover(); } host.style.cursor = 'pointer'; hoverMark = cmInst.markText(hit.from, hit.to, { className: 'tmfe-ctrl-link' }); }); host.addEventListener('mouseleave', clearHover); window.addEventListener('keyup', function (e) { if (e.key === 'Control') clearHover(); }); window.addEventListener('blur', clearHover); host.addEventListener('mousedown', function (e) { if (!e.ctrlKey || !cmInst) return; const pos = cmInst.coordsChar({ left: e.clientX, top: e.clientY }); const hit = paramAt(pos); if (!hit) return; e.preventDefault(); showParamPopup(hit.ref); }); // 拉取变量元数据(名称/参数类型/隐藏条件),失败时弹窗降级为仅引用名 (function loadVarMeta() { const qs = new URLSearchParams(location.search); const gid = qs.get('obsbrandgoodid'); const tt = qs.get('tooltype') || 'wardrobe'; if (!gid) return; fetch('/editor/api/site/editordata?obsbrandgoodid=' + encodeURIComponent(gid) + '&doupdate=false&tooltype=' + encodeURIComponent(tt), { credentials: 'include' }) .then(function (r) { return r.json(); }) .then(function (j) { const ed = j.editorData || (j.d && j.d.editorData); ((ed && ed.inputs) || []).forEach(function (x) { VAR_META[x.paramName] = x; }); }) .catch(function () {}); })(); body.appendChild(right); root.appendChild(body); content.appendChild(root); // CodeMirror 初始化 cmInst = CM(host, { value: origText, mode: 'spellite', lineWrapping: true, viewportMargin: Infinity, matchBrackets: true, // 光标停在括号旁时自动高亮匹配的另一半 styleActiveLine: true, // 当前行高亮(上下边框线见 CSS) lineNumbers: true, // 行号 // Ctrl+Click 不再加光标(误触频繁),多光标改由 Alt+Click 触发 configureMouse: function (cm, repeat, event) { return { unit: 'char', addNew: event.altKey }; }, foldGutter: { rangeFinder: tmfeFoldRange }, // 折叠标记(括号组 + 三元,函数调用括号不折) gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'], indentUnit: 2, tabSize: 2, extraKeys: { 'Ctrl-/': toggleLineComment, 'Cmd-/': toggleLineComment, // Tab 统一插两个空格(默认行为会插 \t);选区则整体加缩进 'Tab': function (cm) { if (cm.somethingSelected()) cm.indentSelection('add'); else cm.replaceSelection(' ', 'end'); }, }, }); // ===== 彩虹括号 + 彩虹缩进线(对齐 VSCode Bracket Pair Colorizer / indentRainbow 效果)===== // 彩虹括号:markText 方案(CM5 overlay 禁止带状态,且 overlay 的行内 span 高度不足会导致竖线断开)。 let rbMarks = []; let rbTimer = null; function colorizeRainbowBrackets() { const found = []; let depth = 0; cmInst.getValue().split('\n').forEach(function (line, ln) { let inStr = false; for (let i = 0; i < line.length; i++) { const c = line[i]; if (c === '"') inStr = !inStr; else if (c === '(' && !inStr) { found.push({ ln: ln, ch: i, d: depth }); depth++; } else if (c === ')' && !inStr) { depth = Math.max(0, depth - 1); found.push({ ln: ln, ch: i, d: depth }); } } }); cmInst.operation(function () { rbMarks.forEach(function (m) { m.clear(); }); rbMarks = found.map(function (f) { return cmInst.markText({ line: f.ln, ch: f.ch }, { line: f.ln, ch: f.ch + 1 }, { className: 'tmfe-rb-' + (f.d % TMFE_RAINBOW_N) }); }); }); } cmInst.on('change', function () { clearTimeout(rbTimer); rbTimer = setTimeout(colorizeRainbowBrackets, 120); }); colorizeRainbowBrackets(); // 彩虹缩进线:在 .CodeMirror-lines 文字层后面插一个绝对定位绘制层, // 每行每级画一个整行高的色块(相邻行色块重叠 0.5px → 竖线视觉上连续不断), // 色块左边框 = 该级竖线,色块背景 = 彩虹缩进色。坐标用 charCoords 实测校准。 let igLayer = null; let igPending = false; function igSchedule() { if (igPending) return; igPending = true; requestAnimationFrame(function () { igPending = false; igRender(); }); } function igRender() { if (!igLayer || !igLayer.parentNode) return; const vp = cmInst.getViewport(); const cw = cmInst.defaultCharWidth(); const IND = 2; // 缩进单位(空格数),与格式化器一致 const layerRect = igLayer.getBoundingClientRect(); if (!layerRect.width && !layerRect.height) return; // 弹窗还没铺开,等下次 update const parts = []; for (let ln = vp.from; ln <= vp.to; ln++) { const text = cmInst.getLine(ln); if (!text) continue; const levels = Math.floor((text.match(/^ */)[0].length) / IND); if (!levels) continue; const a = cmInst.charCoords({ line: ln, ch: 0 }, 'page'); const top = a.top - layerRect.top; const h = a.bottom - a.top + 0.5; // 相邻行重叠半像素,消除接缝 for (let L = 1; L <= levels; L++) { const x = a.left + (L - 1) * IND * cw - layerRect.left; const ci = (L - 1) % TMFE_RAINBOW_N; // 级 L 颜色 = 包围括号深度 L-1 parts.push('
'); } } igLayer.innerHTML = parts.join(''); } try { igLayer = document.createElement('div'); igLayer.className = 'tmfe-ig-layer'; const linesEl = cmInst.getWrapperElement().querySelector('.CodeMirror-lines'); linesEl.insertBefore(igLayer, linesEl.firstChild); cmInst.on('update', igSchedule); cmInst.on('viewportChange', igSchedule); cmInst.on('refresh', igSchedule); igSchedule(); } catch (eIG) { console.error('[TMFE] 缩进线层初始化失败:', eIG); } // ===== # / @ 实时补全(对齐原生检索框:# 查系统变量,@ 查部件/图层引用名)===== // @ 的候选 = 模型部件/图层引用名(右侧面板"引用名"手改后实时生效), // 数据源是原生 React 树上的 formulaToolkit 实例(getElementReferenceInfo)。 let cmpToolkit = null; function getFormulaToolkit() { if (cmpToolkit) return cmpToolkit; let el = root.parentElement; for (let hops = 0; el && hops < 15; hops++, el = el.parentElement) { const rk = Object.keys(el).find(function (key) { return key.indexOf('__reactInternalInstance') === 0; }); if (!rk) continue; let f = el[rk]; for (let fh = 0; f && fh < 30; fh++, f = f.return) { const p = f.memoizedProps; if (p && p.formulaToolkit) { cmpToolkit = p.formulaToolkit; return cmpToolkit; } } } return null; } function toolkitRefInfo(method) { const tk = getFormulaToolkit(); if (!tk || typeof tk[method] !== 'function') return null; try { const r = tk[method](); return Array.isArray(r) ? r : null; } catch (e) { return null; } } function buildCompleteItems(sigil, kw) { const out = []; const refs = sigil === '#' ? (toolkitRefInfo('getParameterReferenceInfo') || []) : (toolkitRefInfo('getElementReferenceInfo') || []); const dot = kw.indexOf('.'); if (dot >= 0 && sigil === '#') { // #参数.子级:商品类参数(材质/造型/部件)出系统字段表(与原生 #CZ. 一致) const segs = kw.split('.'); if (segs.length === 2) { // 商品字段无更深嵌套,只支持一级 const rv = (segs[0] || '').toLowerCase(); const akw = (segs[1] || '').toLowerCase(); let paramName = null, vt = null; Object.keys(VAR_META).forEach(function (k) { if (k.toLowerCase() === rv) { paramName = k; vt = VAR_META[k] && VAR_META[k].valueType; } }); if (vt === null && paramName === null) vt = undefined; // 未知参数:兜底显示商品字段表 const fields = brandGoodFieldsFor(vt); (fields || []).forEach(function (f) { if (akw && (f.value + ' ' + (f.info || '')).toLowerCase().indexOf(akw) < 0) return; out.push({ ins: '#' + (paramName || segs[0]) + '.' + f.value, name: f.value, lbl: f.info || '' }); }); } } else if (dot >= 0) { // 多级路径补全:前缀.子级(属性可继续嵌套,如 @AAA.position.Y;# 参数有子级时同样适用) const segs = kw.split('.'); let list = refs; const canon = []; // 已解析段的原生规范值,用于拼接完整插入路径 for (let i = 0; i < segs.length - 1; i++) { let seg = segs[i].toLowerCase(); let selfPfx = ''; if (i === 0 && sigil === '@' && seg.indexOf('self') === 0) { // @self引用名 与 @引用名 是同一引用(计算口径不同),路径解析时剥掉 self 前缀 selfPfx = 'self'; seg = seg.slice(4); } const hit = (list || []).find(function (r) { return r && String(r.value).toLowerCase() === seg; }); if (!hit) { list = null; canon.length = 0; break; } canon.push(selfPfx + hit.value); list = hit.children || []; } const akw = (segs[segs.length - 1] || '').toLowerCase(); (list || []).forEach(function (c) { if (!c || !c.value) return; if (akw && (c.value + ' ' + (c.info || '')).toLowerCase().indexOf(akw) < 0) return; // 只显示属性本身,不带 "父级." 前缀与 "父标签 · " 前缀(插入时仍写全路径) out.push({ ins: sigil + canon.join('.') + (canon.length ? '.' : '') + c.value, name: c.value, lbl: c.info || '' }); }); } else if (sigil === '@') { // 部件/图层引用名;引用名可随时手改,必须每次实时向原生 toolkit 取 // 每项追加同名 self 前缀项:@selfAAA 与 @AAA 同一引用,计算口径不同,用法相同 refs.forEach(function (r) { if (!r || !r.value) return; if (kw && (r.value + ' ' + (r.info || '')).toLowerCase().indexOf(kw) < 0) return; out.push({ ins: '@' + r.value, name: r.value, lbl: r.info || '' }); out.push({ ins: '@self' + r.value, name: 'self' + r.value, lbl: r.info || '' }); }); } else { const seen = new Set(); refs.forEach(function (v) { if (!v || !v.value || seen.has(v.value)) return; if (kw && (v.value + ' ' + (v.info || '')).toLowerCase().indexOf(kw) < 0) return; seen.add(v.value); out.push({ ins: '#' + v.value, name: v.value, lbl: v.info || '' }); }); if (!refs.length) { // 回退:页面变量表 DOM 采集 PAGE_VARS.forEach(function (v) { if (!v.ref || seen.has(v.ref)) return; if (kw && (v.ref + ' ' + v.name).toLowerCase().indexOf(kw) < 0) return; seen.add(v.ref); out.push({ ins: '#' + v.ref, name: v.ref, lbl: v.name || '' }); }); } // 函数项:#abs() 等(与左侧函数树一致),插入自带括号、落点在括号内 FUNC_GROUPS.forEach(function (g) { g.items.forEach(function (it) { if (it.charAt(0) !== '#') return; const nm = it.slice(1).replace(/\(\)$/, ''); if (!nm || seen.has(nm)) return; if (kw && nm.toLowerCase().indexOf(kw) < 0) return; seen.add(nm); out.push({ ins: '#' + nm + '()', name: nm, lbl: g.name }); }); }); } return out.slice(0, 50); } const cmpEl = document.createElement('div'); cmpEl.className = 'tmfe-cmplete'; right.appendChild(cmpEl); let cmpItems = [], cmpSel = 0, cmpFrom = null, cmpSuppress = false; function hideComplete() { cmpEl.style.display = 'none'; cmpItems = []; } function completeVisible() { return cmpItems.length > 0; } function renderComplete() { cmpEl.innerHTML = cmpItems.map(function (it, i) { return '
' + escapeHtml(it.name) + '' + (it.lbl ? '' + escapeHtml(it.lbl) + '' : '') + '
'; }).join(''); const sel = cmpEl.querySelector('.tmfe-item-sel'); if (sel && sel.scrollIntoView) sel.scrollIntoView({ block: 'nearest' }); } function acceptComplete() { const it = cmpItems[cmpSel]; if (!it || !cmpFrom || !cmInst) return; const cur = cmInst.getCursor(); let ins = it.ins; // 引用(#/@ 开头)原生不补空格;仅短大写名与逻辑运算符前后补空格提升可读性 if (!/^[@#]/.test(it.ins) && (/^[A-Z]{2,}$/.test(it.name) || it.name === '&&' || it.name === '||')) ins = ' ' + it.ins + ' '; cmInst.replaceRange(ins, cmpFrom, cur); if (/\(\)$/.test(ins)) { const c = cmInst.getCursor(); cmInst.setCursor({ line: c.line, ch: c.ch - 2 }); } cmpSuppress = true; // 接受后光标仍贴着 token,抑制立即重开 hideComplete(); cmInst.focus(); } function checkComplete() { if (!cmInst) return; const cur = cmInst.getCursor(); const before = (cmInst.getLine(cur.line) || '').slice(0, cur.ch); const m = before.match(/([#@])([A-Za-z_][A-Za-z0-9_.]*)?$/); if (!m) { hideComplete(); return; } const items = buildCompleteItems(m[1], (m[2] || '').toLowerCase()); if (!items.length) { hideComplete(); return; } cmpItems = items; cmpSel = 0; cmpFrom = { line: cur.line, ch: cur.ch - m[0].length }; const c = cmInst.cursorCoords(cmpFrom, 'page'); const rr = right.getBoundingClientRect(); cmpEl.style.display = 'block'; const w = cmpEl.offsetWidth, h = cmpEl.offsetHeight; let x = c.left - rr.left, y = c.bottom - rr.top + 2; if (x + w > rr.width - 8) x = Math.max(4, rr.width - w - 8); if (y + h > rr.height - 8) y = Math.max(4, y - h - 22); cmpEl.style.left = x + 'px'; cmpEl.style.top = y + 'px'; renderComplete(); } cmInst.on('inputRead', function (_, chg) { cmpSuppress = false; const text = (chg.text || []).join(''); if (/^[\s+\-*/^%=!<>:&|,)\]]/.test(text)) { hideComplete(); return; } if (!cmpSuppress) checkComplete(); }); cmInst.on('keydown', function (_, e) { if (!completeVisible()) return; if (e.key === 'ArrowDown') { cmpSel = (cmpSel + 1) % cmpItems.length; renderComplete(); e.preventDefault(); } else if (e.key === 'ArrowUp') { cmpSel = (cmpSel - 1 + cmpItems.length) % cmpItems.length; renderComplete(); e.preventDefault(); } else if (e.key === 'Enter' || e.key === 'Tab') { acceptComplete(); e.preventDefault(); } else if (e.key === 'Escape') { hideComplete(); e.preventDefault(); } else if (e.key === 'Backspace' || e.key === 'Delete') { // 删除不触发 inputRead:延迟一拍重估,token 删短则重新过滤、删空则关闭 setTimeout(checkComplete, 0); } }); cmInst.on('scroll', hideComplete); // ===== 选中文本求值:左键选择文本松开后弹出「计算」按钮(原生同款交互),点击求片段值 ===== let evalValueType = null; function getCurValueType() { if (evalValueType) return evalValueType; let el = root.parentElement; for (let hops = 0; el && hops < 15 && !evalValueType; hops++, el = el.parentElement) { const rk = Object.keys(el).find(function (key) { return key.indexOf('__reactInternalInstance') === 0; }); if (!rk) continue; let f = el[rk]; for (let fh = 0; f && fh < 30; fh++, f = f.return) { const p = f.memoizedProps; if (p && typeof p.valueType === 'string' && p.eventKey !== undefined) { evalValueType = p.valueType; break; } } } return evalValueType || 'float'; } const evalEl = document.createElement('div'); evalEl.className = 'tmfe-evalpop'; right.appendChild(evalEl); function hideEvalPop() { evalEl.style.display = 'none'; evalEl.innerHTML = ''; delete evalEl.dataset.sel; } function placeEvalPop(x, y) { const rr = right.getBoundingClientRect(); let px = x - rr.left, py = y - rr.top; const w = evalEl.offsetWidth, h = evalEl.offsetHeight; if (px + w > rr.width - 8) px = Math.max(4, rr.width - w - 8); if (py + h > rr.height - 8) py = Math.max(4, py - h - 24); evalEl.style.left = px + 'px'; evalEl.style.top = py + 'px'; } function showEvalResult(text) { evalEl.innerHTML = '' + escapeHtml(text) + ''; placeEvalPop(evalEl.dataset._x || 0, evalEl.dataset._y || 0); } cmInst.getWrapperElement().addEventListener('mouseup', function (e) { if (e.button !== 0) return; setTimeout(function () { const sel = cmInst.getSelection(); if (!sel || !sel.trim()) { hideEvalPop(); return; } const c = cmInst.cursorCoords(cmInst.getCursor('head'), 'page'); evalEl.dataset.sel = sel; evalEl.dataset._x = c.left; evalEl.dataset._y = c.bottom + 4; evalEl.innerHTML = ''; evalEl.style.display = 'block'; placeEvalPop(c.left, c.bottom + 4); }, 0); }); cmInst.on('mousedown', hideEvalPop); cmInst.on('keydown', function (_, e) { if (e.key === 'Escape') hideEvalPop(); }); evalEl.addEventListener('mousedown', function (e) { e.preventDefault(); }); // 防止点击按钮时选区丢失/编辑器失焦 evalEl.addEventListener('click', function (e) { const btn = e.target.closest('.tmfe-evalbtn'); if (!btn) return; const text = evalEl.dataset.sel || ''; if (!text) return; btn.disabled = true; btn.textContent = '计算中…'; const done = function (msg) { showEvalResult(msg); res.textContent = msg; // 同步到顶部工具栏 }; const tk = getFormulaToolkit(); if (!tk || typeof tk.calculateFormula !== 'function') { done('计算失败:未找到原生求值入口'); return; } // 片段类型可能与当前参数类型不同(如选中布尔表达式):失败时依次换类型重试。 // 不重试 string——string 对任意文本都原样返回,会掩盖真实错误 const vt = getCurValueType(); const types = [vt, 'boolean', 'float', 'int'].filter(function (t, i, arr) { return t && arr.indexOf(t) === i; }); let lastErr = null; (function tryNext(i) { if (i >= types.length) { const info = lastErr && lastErr.validateResult && lastErr.validateResult.info; done('计算出错:' + (info || '表达式无法解析')); return; } Promise.resolve().then(function () { return tk.calculateFormula(text, types[i]); }) .then(function (r) { if (r && !r.error && r.value !== text) { done(r.value); return; } lastErr = r; tryNext(i + 1); }) .catch(function (err) { done('计算失败:' + String((err && err.message) || err).slice(0, 80)); }); })(0); }); cmInst.on('blur', function () { setTimeout(hideComplete, 150); }); cmpEl.addEventListener('mousedown', function (e) { e.preventDefault(); // 防止编辑器先失焦导致弹窗闪没 const item = e.target.closest('.tmfe-cmplete-item'); if (item) { cmpSel = parseInt(item.dataset.i, 10) || 0; acceptComplete(); } }); // 注:字段面板来源是页面变量表,不随输入变化,这里不需要 change 监听 // 光标联动:光标落在函数名/函数括号内 → 左侧函数列表滚动定位并选中该函数; // 光标落在 #参数 上 → 中间参数列表滚动定位并选中该参数。 let cursorLinkedKey = null; // 上次联动目标('fn:xxx' / 'fld:#xxx'),避免重复触发 // 反查光标所在括号层是否属于某个函数调用:向前栈扫描最近的未闭合 '(', // 且该 '(' 前面紧邻函数名(#max( / max() function cursorInFnBrackets(pos) { if (!cmInst) return null; let depth = 0; for (let ln = pos.line; ln >= 0; ln--) { const text = cmInst.getLine(ln) || ''; const endCh = (ln === pos.line) ? pos.ch : text.length; for (let i = endCh - 1; i >= 0; i--) { const c = text[i]; if (c === ')') depth++; else if (c === '(') { if (depth === 0) { const m = text.slice(0, i).match(/#?([A-Za-z_][A-Za-z0-9_]*)\s*$/); // FUNC_NAMES 存的是带 # 的名字('#max'),m[0] 含可选的 # 前缀 if (m && FUNC_NAMES.has(m[0])) return m[1]; depth = 1; // 非函数分组括号(如 (a+b) 内层),继续向外找所属函数 } else { depth--; } } } } return null; } function clearCursorLink() { if (!cursorLinkedKey) return; document.querySelectorAll('.tmfe-func-item.cursor-active,.tmfe-field-item.cursor-active') .forEach(function (el) { el.classList.remove('cursor-active'); }); cursorLinkedKey = null; } function linkCursorToPanels() { if (!cmInst) return; const cur = cmInst.getCursor(); const token = cmInst.getTokenAt(cur, true); let fnName = null, fldRef = null; if (token.type === 'builtin') { fnName = token.string.replace(/^#/, ''); } else if (token.type === 'variable-2' && /^#[A-Za-z0-9_\u4e00-\u9fa5]+$/.test(token.string)) { fldRef = token.string.slice(1); } if (!fnName) fnName = cursorInFnBrackets(cur); if (fnName) { const key = 'fn:' + fnName; const item = left.querySelector('.tmfe-func-item[data-fn="#' + fnName + '()"]'); if (!item) { clearCursorLink(); return; } if (cursorLinkedKey !== key) { clearCursorLink(); item.classList.add('cursor-active'); item.click(); // 复用点击逻辑:选中态 + 函数文档同步刷新 item.scrollIntoView({ block: 'nearest' }); cursorLinkedKey = key; } return; } if (fldRef) { const key = 'fld:#' + fldRef; const item = mid.querySelector('.tmfe-field-item[data-fld="#' + fldRef + '"]'); if (!item) { clearCursorLink(); return; } if (cursorLinkedKey !== key) { clearCursorLink(); item.classList.add('cursor-active'); item.scrollIntoView({ block: 'nearest' }); cursorLinkedKey = key; } return; } clearCursorLink(); } cmInst.on('cursorActivity', linkCursorToPanels); // 函数点击 / 双击 const docBox = left.querySelector('.tmfe-func-doc'); let selectedFn = null; left.querySelector('.tmfe-func-tree').addEventListener('click', function (e) { const item = e.target.closest('.tmfe-func-item'); if (!item) return; left.querySelectorAll('.tmfe-func-item.selected').forEach(function (el) { el.classList.remove('selected'); }); item.classList.add('selected'); selectedFn = item.dataset.fn; const d = DOCS[selectedFn] || { t: selectedFn, s: '—', d: '—', e: '—' }; docBox.innerHTML = '

' + escapeHtml(d.t) + '

' + '

语法

' + escapeHtml(d.s) + '

' + '

说明

' + escapeHtml(d.d) + '

' + '

示例

' + escapeHtml(d.e) + '

'; }); left.querySelector('.tmfe-func-tree').addEventListener('dblclick', function (e) { const item = e.target.closest('.tmfe-func-item'); if (!item) return; insertToken(item.dataset.fn); }); // 字段面板双击插入(与函数列表统一为双击操作) mid.querySelector('.tmfe-field-list').addEventListener('dblclick', function (e) { const item = e.target.closest('.tmfe-field-item'); if (item && item.dataset.fld) insertToken(item.dataset.fld); }); function insertToken(tok) { if (!cmInst) return; if (/^#.*\(\)$/.test(tok)) { cmInst.replaceSelection(tok); const cur = cmInst.getCursor(); cmInst.setCursor({ line: cur.line, ch: cur.ch - 1 }); } else if (/^[A-Z]+$/.test(tok) || tok === '&&' || tok === '||') { cmInst.replaceSelection(' ' + tok + ' '); } else { cmInst.replaceSelection(tok); } cmInst.focus(); } // 从页面「变量&属性」面板抓取真实变量(名称 + 引用名 + 所属分组) function collectPageVars() { const out = []; const seen = new Set(); document.querySelectorAll('li.parameter-row-container').forEach(function (li) { const nmEl = li.querySelector('.param-row-item-name'); const idEl = li.querySelector('.param-row-item-id'); const ref = idEl ? (idEl.textContent || '').trim() : ''; if (!ref || seen.has(ref)) return; seen.add(ref); const name = nmEl ? (nmEl.textContent || '').trim() : ''; const panel = li.closest('.tui-collapse-panel'); const headEl = panel ? panel.querySelector('.tui-collapse-panel-header') : null; const sec = headEl ? (headEl.textContent || '').trim() : ''; out.push({ ref: ref, name: name, sec: sec || '变量' }); }); return out; } const PAGE_VARS = collectPageVars(); function updateFieldPanel() { const list = mid.querySelector('.tmfe-field-list'); const kw = (mid.querySelector('.tmfe-field-search input').value || '').trim().toLowerCase(); // 兜底:抓不到页面变量时,退回从当前公式里识别 #参数 / @shelf if (!PAGE_VARS.length) { const val = cmInst ? cmInst.getValue() : ''; const found = []; const seen = new Set(); const re = /[@#][A-Za-z0-9_\u4e00-\u9fa5]+/g; let m; while ((m = re.exec(val)) !== null) { if (!seen.has(m[0])) { seen.add(m[0]); found.push({ ref: m[0], name: '', sec: '当前公式' }); } } PAGE_VARS.push.apply(PAGE_VARS, found); } const groups = []; const gmap = {}; PAGE_VARS.forEach(function (v) { if (kw && (v.ref + ' ' + v.name + ' ' + v.sec).toLowerCase().indexOf(kw) < 0) return; if (!gmap[v.sec]) { gmap[v.sec] = { sec: v.sec, items: [] }; groups.push(gmap[v.sec]); } gmap[v.sec].items.push(v); }); if (!groups.length) { list.innerHTML = '
没有匹配的字段
'; return; } list.innerHTML = groups.map(function (g) { return '
' + '
' + escapeHtml(g.sec) + '
' + g.items.map(function (v) { return '
' + (v.name ? '' + escapeHtml(v.name) + '' : '') + '#' + escapeHtml(v.ref) + '
'; }).join('') + '
'; }).join(''); // 名称列以最长项为基准右对齐,所有引用名从同一条竖线起排 const nameEls = list.querySelectorAll('.fname'); let maxW = 0; nameEls.forEach(function (el) { maxW = Math.max(maxW, el.offsetWidth); }); if (maxW > 0) { nameEls.forEach(function (el) { el.style.width = maxW + 'px'; }); } } mid.querySelector('.tmfe-field-search input') .addEventListener('input', updateFieldPanel); updateFieldPanel(); // 镜像原版「计算结果」 const rv = content.querySelector('.result-value'); if (rv) { const mirror = function () { const t = rv.textContent || ''; res.textContent = (t.trim() ? t : ''); }; new MutationObserver(mirror).observe(rv, { childList: true, subtree: true, characterData: true }); mirror(); } // 底部「格式化」按钮(追加到原页脚按钮区;防重入,避免重复添加) if (dialog) { const btnRow = dialog.querySelector('.tui-dialog-buttons'); if (btnRow && !btnRow.dataset.tmfeFmt) { btnRow.dataset.tmfeFmt = '1'; const fmtBtn = document.createElement('button'); fmtBtn.type = 'button'; fmtBtn.className = 'tui-btn tui-btn--secondary tui-btn--strength-normal tui-btn--size-middle tui-btn--outline tui-btn--subType-1'; fmtBtn.textContent = '格式化'; fmtBtn.title = '只对三元表达式格式化:条件 ? 真 : 假 按层级换行;注释单独成行并与下一行同缩进'; fmtBtn.addEventListener('click', function () { if (!cmInst) return; const v = cmInst.getValue(); const f = fmtFormula(v); if (f !== v) { const cur = cmInst.getCursor(); cmInst.setValue(f); cmInst.setCursor(cur); } cmInst.focus(); }); btnRow.insertBefore(fmtBtn, btnRow.firstChild); // 点「确认」前把新编辑器内容同步回原编辑器 const confirmBtn = btnRow.querySelector('.tui-btn--primary'); if (confirmBtn) { confirmBtn.addEventListener('click', function () { if (cmInst) writeBack(content, cmInst.getValue()); }, true); } } } // 「展开公式解析」分屏:原生会把原编辑器搬进左栏、解析图放右栏。 // 这里改成:左栏继续显示重制界面(绝对定位盖在分屏左栏上),原生编辑器保持隐藏; // 同时把原生编辑器上的行高亮镜像到重制编辑器(图里选中节点时联动) let mirroredLines = []; let lastMirrorSig = null; function clearMirror() { mirroredLines.forEach(function (l) { if (cmInst) cmInst.removeLineClass(l, 'background', 'tmfe-parse-hl'); }); mirroredLines = []; } // 解析图选中节点时,原生编辑器用行内 .ast-selection 装饰 span 标记选中文本 // (宽度和文本非空)。把选中文本映射到重制编辑器(格式化后的文档)的对应行。 function mirrorNativeHighlight() { if (!cmInst) return; const segments = []; content.querySelectorAll('.cm-content .cm-line .ast-selection').forEach(function (s) { if (s.getBoundingClientRect().width > 0 && s.textContent.length) segments.push(s.textContent); }); const sig = segments.join('\u0001'); if (sig === lastMirrorSig) return; lastMirrorSig = sig; clearMirror(); if (!segments.length) return; // 去空白索引映射:选中文本与文档对比时忽略格式化产生的空白差异 const doc = cmInst.getValue(); const stripped = []; const map = []; for (let i = 0; i < doc.length; i++) { if (/\s/.test(doc[i])) continue; stripped.push(doc[i]); map.push(i); } const haystack = stripped.join(''); const linesToMark = new Set(); segments.forEach(function (seg) { const needle = seg.replace(/\s+/g, ''); if (!needle) return; let pos = haystack.indexOf(needle); if (pos === -1) return; const startOff = map[pos]; const endOff = map[Math.min(pos + needle.length - 1, map.length - 1)] + 1; const startLine = cmInst.posFromIndex(startOff).line; const endLine = cmInst.posFromIndex(Math.min(endOff, doc.length)).line; for (let ln = startLine; ln <= endLine; ln++) linesToMark.add(ln); }); linesToMark.forEach(function (ln) { cmInst.addLineClass(ln, 'background', 'tmfe-parse-hl'); mirroredLines.push(ln); }); } new MutationObserver(mirrorNativeHighlight).observe(content, { subtree: true, attributes: true, attributeFilter: ['class', 'style'], childList: true, }); // 拖宽手柄:按住后左右拖动,改写 --tmfe-diag-w(面板宽与编辑器让位联动), // 松开后记忆到 localStorage,下次进入分屏自动恢复 function attachDiagResizer(pane) { if (pane.querySelector('.tmfe-diag-resizer')) return; const h = document.createElement('div'); h.className = 'tmfe-diag-resizer'; h.title = '拖动调节解析图宽度'; pane.appendChild(h); h.addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); const startX = e.clientX; const startW = pane.getBoundingClientRect().width; const contentW = content.getBoundingClientRect().width; const LEFT_COLS = 385; // 函数树 + 字段列固定宽度 const minW = 280; const maxW = Math.max(minW, contentW - LEFT_COLS - 300); // 编辑器至少留 300px h.classList.add('tmfe-dragging'); function onMove(ev) { const w = Math.round(Math.min(maxW, Math.max(minW, startW + (startX - ev.clientX)))); content.style.setProperty('--tmfe-diag-w', w + 'px'); if (cmInst) cmInst.refresh(); } function onUp() { document.removeEventListener('mousemove', onMove, true); document.removeEventListener('mouseup', onUp, true); h.classList.remove('tmfe-dragging'); try { localStorage.setItem('tmfeDiagW', String(Math.round(pane.getBoundingClientRect().width))); } catch (_) { /* 隐私模式等场景忽略 */ } window.dispatchEvent(new Event('resize')); // 通知 x6 画布按最终宽度重排 } document.addEventListener('mousemove', onMove, true); document.addEventListener('mouseup', onUp, true); }); } // 顶部「展开公式解析」按钮改造为展开/收起二合一: // - 进入分屏后:按钮文字改「« 收起公式解析」,点击转发给原生底部收起按钮 // - 底部原生「« 收起公式解析」按钮隐藏(职责已合并到顶部) // layoutSplitUI 随 DOM 变化反复执行,这里所有写操作都先比对再写,保证收敛 function enforceSplitChrome() { let topBtn = null; content.querySelectorAll('button').forEach(function (b) { if (b.closest('.parse-ast-modal')) { if ((b.textContent || '').includes('收起公式解析')) b.style.display = 'none'; return; } if ((b.textContent || '').includes('展开公式解析') || b.dataset.tmfeToggle) topBtn = b; }); if (!topBtn) return; if (!topBtn.dataset.tmfeToggle) { topBtn.dataset.tmfeToggle = '1'; topBtn.dataset.tmfeOrig = topBtn.textContent; topBtn.addEventListener('click', function (e) { if (!content.classList.contains('tmfe-splitmode')) return; // 展开交给原生逻辑 e.stopPropagation(); // 拦下原生「展开」,避免与收起动作叠加 const real = Array.from(content.querySelectorAll('.parse-ast-modal button')) .find(function (x) { return (x.textContent || '').includes('收起公式解析'); }); if (real) real.click(); }); } const want = '« 收起公式解析'; if (topBtn.textContent !== want) topBtn.textContent = want; } function layoutSplitUI() { const modal = content.querySelector('.parse-ast-modal'); const ui = content.querySelector(':scope > .tmfe-root'); if (!ui) return; const splitOn = content.classList.contains('tmfe-splitmode'); if (modal && !splitOn) { // —— 进入解析分屏:在绘制前(MutationObserver 微任务)切换弹窗尺寸: // 宽度 = 页面宽 - 100px(左右各留 50px);高度不变(content 高度是 // calc(100vh-...) 公式,两种状态同值,因此弹窗高度始终不变)—— content.classList.add('tmfe-splitmode'); if (tuiDialog) tuiDialog.style.width = (window.innerWidth - 100) + 'px'; if (geom.bodyEl) geom.bodyEl.style.overflow = 'hidden'; // 分屏容器整体绝对定位铺满内容区,作为图区 pane 的定位基准 const wrap = modal.closest('.tui-splitter-container'); if (wrap) { wrap.style.setProperty('position', 'absolute', 'important'); wrap.style.setProperty('top', '0', 'important'); wrap.style.setProperty('left', '0', 'important'); wrap.style.setProperty('width', '100%', 'important'); wrap.style.setProperty('height', '100%', 'important'); wrap.querySelectorAll(':scope > .tui-splitter-content').forEach(function (pane) { if (pane.contains(modal)) { pane.classList.add('tmfe-diag-pane'); attachDiagResizer(pane); // 恢复上次拖动记忆的图区宽度。仅在尚无内联值时设置: // enter 分支会随 DOM 变化反复执行,不能覆盖拖动中的实时值 try { if (!content.style.getPropertyValue('--tmfe-diag-w')) { const savedW = parseInt(localStorage.getItem('tmfeDiagW'), 10); if (savedW >= 280 && savedW <= 2000) { content.style.setProperty('--tmfe-diag-w', savedW + 'px'); } } } catch (_) { /* 忽略 */ } } else { // 左栏 = 原生编辑器。不能 display:none——CM6 对隐藏编辑器不渲染 // .ast-selection 装饰,解析图选中的行联动会失效;改为移出屏幕 pane.style.position = 'fixed'; pane.style.left = '-9999px'; pane.style.top = '0'; pane.style.visibility = 'hidden'; } }); wrap.querySelectorAll(':scope > [class*="gutter"]').forEach(function (g) { g.style.display = 'none'; // 分割条 }); } // 重制界面铺满整个内容区(编辑器列经 CSS 让出右侧图区条带) content.style.position = 'relative'; ui.style.position = 'absolute'; ui.style.left = '0'; ui.style.top = '0'; ui.style.right = '0'; ui.style.bottom = '0'; ui.style.width = '100%'; ui.style.height = '100%'; if (cmInst) setTimeout(function () { cmInst.refresh(); }, 0); // 提示 x6 画布按新容器尺寸重排 setTimeout(function () { window.dispatchEvent(new Event('resize')); }, 60); setTimeout(mirrorNativeHighlight, 0); enforceSplitChrome(); } else if (modal && splitOn) { // 已在分屏中:React 重渲染可能重建顶部按钮,持续强制二合一状态 enforceSplitChrome(); } else if (!modal && splitOn) { // —— 退出解析分屏:宽度恢复 980,高度不变(公式恒定)—— // 重制界面的绝对定位保留:React 收起后会残留分屏包装节点, // 保持 absolute 铺满即可盖住它(若回文档流会被残留节点挤到下方) content.classList.remove('tmfe-splitmode'); if (tuiDialog) tuiDialog.style.width = '980px'; content.querySelectorAll('.tmfe-diag-pane').forEach(function (p) { p.classList.remove('tmfe-diag-pane'); }); // 顶部按钮恢复原生「展开公式解析 »」文案 content.querySelectorAll('[data-tmfe-toggle]').forEach(function (b) { if (b.dataset.tmfeOrig) b.textContent = b.dataset.tmfeOrig; }); clearMirror(); } } // 注意 subtree:true:分屏容器出现在更深层级,只监听直接子节点收不到通知; // 回调在微任务里执行(早于绘制),因此尺寸切换不会产生放大的中间帧 new MutationObserver(function () { layoutSplitUI(); }).observe(content, { childList: true, subtree: true }); // React 若在分屏期间重设弹窗宽度(内联 style 变化不触发 childList),在此兜底锁回 if (tuiDialog) { new MutationObserver(function () { if (!content.classList.contains('tmfe-splitmode')) return; const want = (window.innerWidth - 100) + 'px'; if (tuiDialog.style.getPropertyValue('width') !== want) { tuiDialog.style.width = want; } }).observe(tuiDialog, { attributes: true, attributeFilter: ['style'] }); } window.addEventListener('resize', function () { // 分屏态下跟随视口:宽度 = 页面宽 - 100;高度是 calc 公式自动跟随 if (content.classList.contains('tmfe-splitmode') && tuiDialog) { tuiDialog.style.width = (window.innerWidth - 100) + 'px'; } if (content.querySelector('.parse-ast-modal')) layoutSplitUI(); }); setTimeout(function () { cmInst.refresh(); cmInst.focus(); }, 50); } function hookDialog() { const content = document.querySelector('.formula-editor-dialog-content-wrap.new-formula-editor'); if (!content || content.dataset.tmfe) return; content.dataset.tmfe = '1'; injectCss(); buildUI(content); } new MutationObserver(function () { hookDialog(); }) .observe(document.body, { childList: true, subtree: true }); hookDialog(); })();