---
name: 绑定/解除 Detail 关系
description: 选中要隐藏的元素运行,然后点击作为触发器的Target;不选元素运行则进入解除模式。
author: ymjr
version: 1.0.0
license: MIT
usage: 绑定:先选中需要被折叠/展开的子元素,运行此脚本后,再点击画板上的主元素(Target)进行关联;解除:不选任何元素运行脚本,然后点击主元素(Target)解除关联。
features:
- 通过临时拦截 shouldSelectElement 提供交互式的“靶向绑定”体验
- 自动向元素中注入 customData.detail 配置
- 支持配置子元素是否在拖拽 Target 时跟随移动
dependencies:
- 依赖 Feature-Detail-Engine 提供实际的展开/折叠显示功能与拖拽跟随算法
autorun: false
---
/*
```javascript
*/
var locales = {
zh: {
no_engine: "EA_Core 引擎未加载,无法使用靶向绑定",
mode_unbind: "🔓 解除模式: 请在下方画布点击需要解除的 Target 元素",
mode_bind: "🔗 绑定模式: 已选中 {count} 个子元素,请点击作为 Target 的主元素",
cancel_btn: "取消操作",
cancel_notice: "已取消绑定操作",
notice_restored: "✅ 已取消 Detail 绑定并恢复可见性",
notice_not_target: "该元素不是 Target 元素,操作取消",
notice_inside_child: "Target 不能在选中的子元素内,操作取消",
suggester_yes: "是 (拖拽 Target 时,子元素跟随移动)",
suggester_no: "否 (固定在原位置)",
suggester_prompt: "Detail 元素是否跟随 Target 相对移动?",
bind_cancelled: "绑定已取消",
bind_success: "✅ 绑定成功!已关联 {count} 个子元素"
},
en: {
no_engine: "EA_Core engine not loaded, targeting bind unavailable",
mode_unbind: "🔓 Unbind Mode: Click the Target Element on canvas to unbind",
mode_bind: "🔗 Bind Mode: {count} children selected. Click the Target Element.",
cancel_btn: "Cancel",
cancel_notice: "Bind operation cancelled",
notice_restored: "✅ Detail bind cancelled and visibility restored",
notice_not_target: "This element is not a Target. Operation cancelled",
notice_inside_child: "Target cannot be inside selected children. Operation cancelled",
suggester_yes: "Yes (Children follow Target movement)",
suggester_no: "No (Fixed position)",
suggester_prompt: "Should Detail elements move relative to Target?",
bind_cancelled: "Binding cancelled",
bind_success: "✅ Bind successful! {count} children linked"
}
};
const HOOK_NAME = "ymjr.action.detail-binder";
const BANNER_ID = "ymjr-detail-binder-banner";
const selectedEls = ea.getViewSelectedElements();
const core = window.EA_Core;
if (!core) {
// 2. 直接使用 t() 函数,什么都不用引入
new Notice(t("no_engine"));
return;
}
core.unregisterHook(HOOK_NAME);
const existingBanner = document.getElementById(BANNER_ID);
if (existingBanner) existingBanner.remove();
const mode = selectedEls.length === 0 ? 'unbind' : 'bind';
// 3. 动态参数可以通过第二个参数传入,自动替换 {count}
const bannerText = mode === 'unbind'
? t("mode_unbind")
: t("mode_bind", { count: selectedEls.length });
const banner = document.createElement("div");
banner.id = BANNER_ID;
banner.style.cssText = `
position: absolute; top: 20px; left: 50%; transform: translateX(-50%);
background-color: var(--background-primary, #ffffff);
color: var(--text-normal, #333);
border: 2px solid var(--interactive-accent, #3b82f6);
padding: 10px 20px; border-radius: 8px; z-index: 99999;
font-family: sans-serif; font-size: 14px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex; align-items: center; gap: 16px; pointer-events: auto;
`;
banner.innerHTML = `
${bannerText}
`;
document.body.appendChild(banner);
const cleanup = () => {
const b = document.getElementById(BANNER_ID);
if (b) b.remove();
core.unregisterHook(HOOK_NAME);
};
document.getElementById("ymjr-detail-cancel-btn").onclick = () => {
cleanup();
new Notice(t("cancel_notice"));
};
core.registerHook(HOOK_NAME, 'shouldSelectElement', (ctx) => {
const { element } = ctx;
if (!element) return;
cleanup();
ctx.shouldSelect = false;
if (mode === 'unbind') {
if (element.customData?.detail) {
ea.copyViewElementsToEAforEditing([element]);
const eaTarget = ea.getElement(element.id);
const detailIds = eaTarget.customData.detail.ids || [];
const detailsToReset = detailIds.map(id => ea.getViewElements().find(e => e.id === id)).filter(Boolean);
ea.copyViewElementsToEAforEditing(detailsToReset);
detailsToReset.forEach(child => {
const eaChild = ea.getElement(child.id);
if (eaChild?.customData?.hide) delete eaChild.customData.hide;
if (eaChild?.customData?.isDetailOf) delete eaChild.customData.isDetailOf;
});
delete eaTarget.customData.detail;
ea.addElementsToView();
new Notice(t("notice_restored"));
} else {
new Notice(t("notice_not_target"));
}
} else {
if (selectedEls.some(el => el.id === element.id)) {
new Notice(t("notice_inside_child"));
return;
}
(async () => {
const isRelative = await utils.suggester(
[t("suggester_yes"), t("suggester_no")],
[true, false],
t("suggester_prompt")
);
if (isRelative === undefined) {
new Notice(t("bind_cancelled"));
return;
}
const detailIds = selectedEls.map(el => el.id);
ea.copyViewElementsToEAforEditing([...selectedEls, element]);
const eaTarget = ea.getElement(element.id);
eaTarget.customData = {
...eaTarget.customData,
detail: { status: "show", ids: detailIds, relative: isRelative }
};
selectedEls.forEach(el => {
const eaChild = ea.getElement(el.id);
eaChild.customData = { ...(eaChild.customData || {}), isDetailOf: element.id };
});
await ea.addElementsToView();
new Notice(t("bind_success", { count: detailIds.length }));
})();
}
}, 999);