--- name: 高级元素筛选器 (Table Builder版) description: 带有实时感知和多级联动的表格化条件筛选器。 author: ymjr version: 1.0.0 license: MIT usage: 开启后常驻,支持多级联动条件。当鼠标在画布上重新选择范围时,会自动更新基准范围。 dependencies: - 依赖 EA_Core 的 handleCanvasPointerUp 钩子来感知手动选择。 --- /* ```javascript */ const SCRIPT_ID = "ymjr.feature.table-selector"; const UI_ID = "ymjr-table-selector-ui"; if (ExcalidrawAutomate.plugin._ymjr_advancedSelect) { ExcalidrawAutomate.plugin._ymjr_advancedSelect.close(); } // 1. 多国语言 var locales = { zh: { title: "⚡ 高级筛选构建器", add_filter: "+ 添加条件", reset_scope: "重置为全画板", scope_status: "当前基准范围:{n} 个元素", logic_and: "并且 (AND)", logic_or: "或者 (OR)", logic_not: "排除 (NOT)", op_eq: "等于 (==)", op_neq: "不等于 (!=)", op_gt: "大于 (>)", op_lt: "小于 (<)", op_gte: "大于等于 (>=)", op_lte: "小于等于 (<=)", op_contains: "包含", del: "✕", no_filters: "暂无筛选条件,添加一行开始筛选。" }, en: { title: "⚡ Query Builder", add_filter: "+ Add Condition", reset_scope: "Reset Scope (All)", scope_status: "Base Scope: {n} elements", logic_and: "AND", logic_or: "OR", logic_not: "NOT", op_eq: "Equals (==)", op_neq: "Not Eq (!=)", op_gt: "Greater (>)", op_lt: "Less (<)", op_gte: ">= ", op_lte: "<=", op_contains: "Contains", del: "✕", no_filters: "No filters. Add one to start." } }; // 2. 属性字典(定义联动规则) const PROP_CONFIG = { type: { name: "元素类型 (Type)", type: "select", ops: ["op_eq", "op_neq"], options: ["text", "rectangle", "ellipse", "diamond", "arrow", "line", "freedraw", "image"] }, width: { name: "宽度 (Width)", type: "number", ops: ["op_gt", "op_lt", "op_eq", "op_gte", "op_lte"] }, height: { name: "高度 (Height)", type: "number", ops: ["op_gt", "op_lt", "op_eq", "op_gte", "op_lte"] }, strokeColor: { name: "边框颜色 (Stroke)", type: "text", ops: ["op_eq", "op_neq", "op_contains"] }, backgroundColor: { name: "背景颜色 (BgColor)", type: "text", ops: ["op_eq", "op_neq", "op_contains"] }, strokeWidth: { name: "边框粗细", type: "number", ops: ["op_eq", "op_gt", "op_lt"] }, opacity: { name: "不透明度 (0-100)", type: "number", ops: ["op_eq", "op_gt", "op_lt"] }, fontSize: { name: "字号 (FontSize)", type: "number", ops: ["op_eq", "op_gt", "op_lt"] } }; // 3. 核心状态管理 const state = { // 默认基准池:如果打开时有选中,基准就是选中;如果没有,基准是全部 baseElements: ea.getViewSelectedElements().length > 0 ? ea.getViewSelectedElements() : ea.getViewElements(), // 筛选条件列表 filters: [], // 防止代码触发的选中改变被当做用户的鼠标操作 isScriptApplying: false }; // 防抖 const debounce = (fn, delay = 150) => { let timer = null; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); }; }; const createUI = () => { document.getElementById(UI_ID)?.remove(); const container = document.createElement("div"); container.id = UI_ID; // UI 样式 const style = document.createElement("style"); style.textContent = ` #${UI_ID} { position: absolute; top: 15%; left: 55%; width: 560px; background: var(--background-primary); border: 1px solid var(--background-modifier-border); border-radius: 12px; box-shadow: 0 12px 40px rgba(0,0,0,0.25); font-family: var(--font-interface); color: var(--text-normal); z-index: 9999; backdrop-filter: blur(12px); display: flex; flex-direction: column; } .ym-header { padding: 12px 16px; background: var(--background-secondary); border-bottom: 1px solid var(--background-modifier-border); display: flex; justify-content: space-between; align-items: center; cursor: grab; border-radius: 12px 12px 0 0; font-weight: 600; } .ym-header:active { cursor: grabbing; } .ym-close-btn { cursor: pointer; color: var(--text-muted); } .ym-close-btn:hover { color: var(--text-error); } .ym-body { padding: 16px; max-height: 400px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; } .ym-row { display: flex; gap: 8px; align-items: center; animation: ym-slide-in 0.2s ease-out; } @keyframes ym-slide-in { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: translateX(0); } } .ym-select, .ym-input { flex: 1; padding: 6px; background: var(--background-modifier-form-field); border: 1px solid var(--background-modifier-border); border-radius: 6px; color: var(--text-normal); font-size: 0.85em; outline: none; } .ym-select:focus, .ym-input:focus { border-color: var(--interactive-accent); } .ym-logic-box { width: 85px; } .ym-op-box { width: 100px; } .ym-del-btn { background: transparent; border: none; color: var(--text-error); cursor: pointer; padding: 4px 8px; border-radius: 4px; opacity: 0.6; } .ym-del-btn:hover { opacity: 1; background: var(--background-modifier-error-hover); } .ym-footer { padding: 12px 16px; border-top: 1px solid var(--background-modifier-border); display: flex; justify-content: space-between; align-items: center; background: var(--background-primary-alt); border-radius: 0 0 12px 12px; } .ym-btn { padding: 6px 14px; border: none; border-radius: 6px; cursor: pointer; font-size: 0.85em; font-weight: 500; } .ym-btn-primary { background: var(--interactive-accent); color: var(--text-on-accent); } .ym-btn-text { background: transparent; color: var(--text-muted); text-decoration: underline; } .ym-btn-text:hover { color: var(--text-normal); } .ym-status { font-size: 0.8em; color: var(--text-success); font-weight: bold; } .ym-empty { text-align: center; font-size: 0.85em; color: var(--text-muted); padding: 20px 0; } `; container.appendChild(style); container.innerHTML += `
${t("title")} ✕
`; document.body.appendChild(container); // 4. 渲染循环引擎 const renderList = () => { const listDiv = document.getElementById(`${UI_ID}-list`); document.getElementById(`${UI_ID}-status`).innerText = t("scope_status").replace("{n}", state.baseElements.length); if (state.filters.length === 0) { listDiv.innerHTML = `
${t("no_filters")}
`; return; } listDiv.innerHTML = ''; state.filters.forEach((filter, index) => { const row = document.createElement("div"); row.className = "ym-row"; // A. 逻辑关系下拉框 (第一行隐藏此框或显示为"Where") let logicHTML = ''; if (index > 0) { logicHTML = ` `; } else { logicHTML = `
Where
`; } // B. 属性下拉框 let propOptions = Object.keys(PROP_CONFIG).map(k => ``).join(''); // C. 操作符下拉框 (动态联动) const currentPropDef = PROP_CONFIG[filter.prop]; let opOptions = currentPropDef.ops.map(opKey => ``).join(''); // D. 值输入框 (动态联动类型) let valHTML = ''; if (currentPropDef.type === 'select') { let valOpts = currentPropDef.options.map(o => ``).join(''); valHTML = ``; } else { let inputType = currentPropDef.type === 'number' ? 'number' : 'text'; valHTML = ``; } row.innerHTML = ` ${logicHTML}
${valHTML}
`; listDiv.appendChild(row); }); // 绑定事件:所有的 select 和 input 更改 listDiv.querySelectorAll('select, input').forEach(el => { const eventType = el.tagName === 'INPUT' ? 'input' : 'change'; el.addEventListener(eventType, debounce((e) => { const target = e.target; const idx = parseInt(target.dataset.idx); const key = target.dataset.key; state.filters[idx][key] = target.value; // 联动重置:如果修改了属性,必须重置操作符和默认值 if (key === 'prop') { const newDef = PROP_CONFIG[target.value]; state.filters[idx].op = newDef.ops[0]; state.filters[idx].val = newDef.type === 'select' ? newDef.options[0] : ''; renderList(); // 重绘 UI 显示联动框 } applyFilters(); // 触发画板更新 })); }); // 绑定删除按钮 listDiv.querySelectorAll('.ym-del-btn').forEach(btn => { btn.onclick = (e) => { state.filters.splice(parseInt(e.target.dataset.idx), 1); renderList(); applyFilters(); }; }); }; // 5. 引擎执行逻辑 const applyFilters = () => { // 如果没有条件,恢复为基准全选 if (state.filters.length === 0) { state.isScriptApplying = true; ea.selectElementsInView(state.baseElements); setTimeout(() => state.isScriptApplying = false, 100); return; } const results = state.baseElements.filter(el => { let finalPassed = true; state.filters.forEach((f, idx) => { let elVal = el[f.prop]; let condVal = f.val; if (PROP_CONFIG[f.prop].type === 'number') { condVal = Number(condVal) || 0; elVal = Number(elVal) || 0; } let isMatch = false; switch(f.op) { case 'op_eq': isMatch = elVal === condVal; break; case 'op_neq': isMatch = elVal !== condVal; break; case 'op_gt': isMatch = elVal > condVal; break; case 'op_lt': isMatch = elVal < condVal; break; case 'op_gte': isMatch = elVal >= condVal; break; case 'op_lte': isMatch = elVal <= condVal; break; case 'op_contains': isMatch = String(elVal).toLowerCase().includes(String(condVal).toLowerCase()); break; } if (idx === 0) { finalPassed = isMatch; } else { if (f.logic === 'AND') finalPassed = finalPassed && isMatch; if (f.logic === 'OR') finalPassed = finalPassed || isMatch; if (f.logic === 'NOT') finalPassed = finalPassed && !isMatch; // 这里的 NOT 代表 AND NOT } }); return finalPassed; }); // 标记本次更新是由脚本驱动的,防止 Hook 误判 state.isScriptApplying = true; ea.selectElementsInView(results); // 短暂延迟后解除标记 setTimeout(() => state.isScriptApplying = false, 100); }; // 6. 按钮事件 document.getElementById(`${UI_ID}-add`).onclick = () => { state.filters.push({ logic: 'AND', prop: 'type', op: 'op_eq', val: 'text' }); renderList(); applyFilters(); }; document.getElementById(`${UI_ID}-reset`).onclick = () => { state.baseElements = ea.getViewElements(); renderList(); applyFilters(); }; // 7. 拦截手动鼠标操作 (Hook 注入) const handlePointerUpHook = (contextPayload) => { const { App } = contextPayload; // 如果是由脚本计算触发的选中变化,忽略 if (state.isScriptApplying) return false; // 当鼠标抬起时,获取当前用户的物理实际选中,将其重置为新的基准范围! const userSelection = App.scene.getSelectedElements(App.state); // 更新基准范围 state.baseElements = userSelection.length > 0 ? userSelection : ea.getViewElements(); // 范围变了,立即用当前条件再次过滤新范围 renderList(); if (state.filters.length > 0) { applyFilters(); } return false; }; if (window.EA_Core) { window.EA_Core.registerHook(SCRIPT_ID, 'handleCanvasPointerUp', handlePointerUpHook, 80); } // 初始化渲染 renderList(); if(state.filters.length > 0) applyFilters(); // UI 拖拽 const header = document.getElementById(`${UI_ID}-header`); let isDragging = false, startX, startY, initialX, initialY; header.onmousedown = (e) => { isDragging = true; startX = e.clientX; startY = e.clientY; const rect = container.getBoundingClientRect(); initialX = rect.left; initialY = rect.top; container.style.left = `${initialX}px`; container.style.top = `${initialY}px`; }; document.onmousemove = (e) => { if (isDragging) { container.style.left = `${initialX + (e.clientX - startX)}px`; container.style.top = `${initialY + (e.clientY - startY)}px`; } }; document.onmouseup = () => { isDragging = false; }; const closeUI = () => { if (window.EA_Core) window.EA_Core.unregisterHook(SCRIPT_ID); container.remove(); delete ExcalidrawAutomate.plugin._ymjr_advancedSelect; }; document.getElementById(`${UI_ID}-close`).onclick = closeUI; return { close: closeUI }; }; ExcalidrawAutomate.plugin._ymjr_advancedSelect = createUI();