// ==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 = '
公式编辑器
点击函数查看说明,双击插入到编辑器。
' + escapeHtml(f[0]) + '' + '' + escapeHtml(f[1]) + '
'; } function popSection(label) { return '' + 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 = '