---
name: 选区阵列生成
description: 根据当前选中的元素群体,批量复制并生成网格阵列,支持带公式的智能间距。
author: ymjr
version: 1.0.0
license: MIT
usage: 选中画板中的一个或多个元素,运行脚本后在面板中配置行列数和间距。被选中的元素将作为基础单元被克隆为二维阵列。
features:
- 精确处理复制元素的内部嵌套关系与绑定ID
- 自动将新生成的阵列进行合理的 Group 分组
- 支持带 width/height 变量的间距公式
dependencies:
- 无前置依赖
autorun: false
---
/*
```javascript
*/
var locales = {
zh: {
notice_select: "❌ 请先选中一些元素!",
ui_title: "选区阵列生成",
ui_rows: "行数",
ui_cols: "列数",
ui_spacing_x: "水平间距",
ui_spacing_y: "垂直间距",
ui_confirm: "确认生成",
notice_success: "阵列已生成",
notice_cancelled: "已取消"
},
en: {
notice_select: "❌ Please select some elements first!",
ui_title: "Selection Grid Array",
ui_rows: "Rows",
ui_cols: "Columns",
ui_spacing_x: "Horizontal Spacing",
ui_spacing_y: "Vertical Spacing",
ui_confirm: "Confirm Generate",
notice_success: "Array generated",
notice_cancelled: "Cancelled"
}
};
const { Notice } = ea.obsidian;
const selectedEls = ea.getViewSelectedElements();
if (!selectedEls || selectedEls.length === 0) {
new Notice(t("notice_select"));
return;
}
const box = ea.getBoundingBox(selectedEls);
const baseX = box.topX;
const baseY = box.topY;
const baseWidth = box.width;
const baseHeight = box.height;
let settings = { rows: 4, cols: 4, spacingX: "width", spacingY: "height" };
let previewIDs = [];
function parseDimension(input, w, h) {
if (typeof input === 'number') return input;
if (!input) return 0;
try {
let expression = input.toString().toLowerCase().replace(/width/g, w).replace(/height/g, h);
if (!/^[0-9+\-*/.() ]+$/.test(expression)) return 0;
return new Function('return ' + expression)();
} catch (e) { return 0; }
}
function generateId() { return Math.random().toString(36).substring(2, 10); }
async function runDraw(isPreview = true) {
if (previewIDs.length > 0) {
const api = ea.getExcalidrawAPI();
const next = api.getSceneElements().filter(el => !previewIDs.includes(el.id));
ea.targetView.updateScene({ elements: next, storeAction: "none" });
previewIDs = [];
}
ea.clear();
const { rows, cols, spacingX, spacingY } = settings;
const realSpacingX = parseDimension(spacingX, baseWidth, baseHeight);
const realSpacingY = parseDimension(spacingY, baseWidth, baseHeight);
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
if (i === 0 && j === 0) continue;
const offsetX = j * realSpacingX;
const offsetY = i * realSpacingY;
const newGroupId = generateId();
let cellEls = [];
let idMap = {};
// 【第一遍遍历】:深拷贝并分配全新独立ID,建立新老ID映射表
for (const el of selectedEls) {
const safeClone = JSON.parse(JSON.stringify(el)); // 彻底切断引用
const oldId = safeClone.id;
const newId = generateId();
idMap[oldId] = newId;
safeClone.id = newId;
safeClone.x += offsetX;
safeClone.y += offsetY;
safeClone.seed = Math.floor(Math.random() * 100000);
safeClone.version = 1;
safeClone.versionNonce = 0;
// 隔离 GroupID:保持原有层级,但附加网格坐标后缀
if (safeClone.groupIds && safeClone.groupIds.length > 0) {
safeClone.groupIds = safeClone.groupIds.map(gid => gid + `_r${i}c${j}`);
} else {
safeClone.groupIds = [];
}
safeClone.groupIds.push(newGroupId); // 绑入当前网格格子的总 Group
cellEls.push(safeClone);
}
// 【第二遍遍历】:利用映射表,重新修复绑定关系 (containerId 和 boundElements)
for (const newEl of cellEls) {
if (newEl.containerId && idMap[newEl.containerId]) {
newEl.containerId = idMap[newEl.containerId];
}
if (newEl.boundElements && Array.isArray(newEl.boundElements)) {
newEl.boundElements = newEl.boundElements.map(b => {
return { ...b, id: idMap[b.id] || b.id };
});
}
ea.elementsDict[newEl.id] = newEl;
previewIDs.push(newEl.id);
}
}
}
await ea.addElementsToView(false, !isPreview, false);
}
function createFloatingPanel() {
if (document.getElementById("ea-grid-sel-panel")) return;
const panelWidth = 300;
const panelHeight = 220;
const startLeft = Math.max(0, (window.innerWidth - panelWidth) / 2);
const startTop = Math.max(0, (window.innerHeight - panelHeight) / 2);
const panel = document.createElement('div');
panel.id = "ea-grid-sel-panel";
panel.style.cssText = `position:fixed; top:${startTop}px; left:${startLeft}px; width:${panelWidth}px; background:var(--background-primary); border:1px solid var(--background-modifier-border); box-shadow:0 4px 12px rgba(0,0,0,0.2); border-radius:8px; z-index:9999; display:flex; flex-direction:column;`;
const header = document.createElement('div');
header.style.cssText = `padding:10px 15px; background:var(--background-secondary); cursor:move; border-bottom:1px solid var(--background-modifier-border); display:flex; justify-content:space-between; align-items:center; user-select:none;`;
header.innerHTML = `${t("ui_title")}`;
header.querySelector('button').onclick = () => closePanel(false);
panel.appendChild(header);
const content = document.createElement('div');
content.style.cssText = `padding:15px; display:grid; grid-template-columns: 1fr 1fr; gap:10px;`;
const createInput = (label, key, type="number") => {
const div = document.createElement('div');
div.innerHTML = ``;
const input = document.createElement('input');
input.type = type; input.value = settings[key]; input.style.width = "100%";
input.onchange = (e) => {
settings[key] = type === "number" ? (parseFloat(e.target.value) || 0) : e.target.value;
runDraw(true);
};
div.appendChild(input);
content.appendChild(div);
};
createInput(t("ui_rows"), "rows"); createInput(t("ui_cols"), "cols");
createInput(t("ui_spacing_x"), "spacingX", "text"); createInput(t("ui_spacing_y"), "spacingY", "text");
panel.appendChild(content);
const footer = document.createElement('div');
footer.style.cssText = `padding:10px 15px; border-top:1px solid var(--background-modifier-border); display:flex; justify-content:flex-end;`;
const btn = document.createElement('button');
btn.innerText = t("ui_confirm"); btn.className = "mod-cta"; btn.onclick = () => closePanel(true);
footer.appendChild(btn);
panel.appendChild(footer);
document.body.appendChild(panel);
let isDragging = false, startX, startY, initLeft, initTop;
header.onmousedown = (e) => {
if(e.target.tagName === 'BUTTON') return;
isDragging = true; startX = e.clientX; startY = e.clientY;
const rect = panel.getBoundingClientRect(); initLeft = rect.left; initTop = rect.top;
e.preventDefault();
document.onmousemove = (e) => {
if(!isDragging) return;
let newLeft = Math.max(0, Math.min(initLeft + e.clientX - startX, window.innerWidth - rect.width));
let newTop = Math.max(0, Math.min(initTop + e.clientY - startY, window.innerHeight - rect.height));
panel.style.left = newLeft + 'px'; panel.style.top = newTop + 'px';
};
document.onmouseup = () => { isDragging = false; document.onmousemove = null; document.onmouseup = null; };
};
runDraw(true);
}
function closePanel(confirm) {
const panel = document.getElementById("ea-grid-sel-panel");
if (panel) panel.remove();
if (confirm) {
runDraw(false);
new Notice(t("notice_success"));
} else {
if (previewIDs.length > 0) {
const api = ea.getExcalidrawAPI();
const next = api.getSceneElements().filter(el => !previewIDs.includes(el.id));
ea.targetView.updateScene({ elements: next, storeAction: "none" });
new Notice(t("notice_cancelled"));
}
}
}
createFloatingPanel();