---
name: 生成图形网格
description: 批量生成矩形、圆形、菱形或多边形网格阵列,间距支持动态公式。
author: ymjr
version: 1.0.0
license: MIT
usage: 运行脚本,打开“万能网格生成器”面板,配置行列数、形状类型、尺寸与间距。支持拖拽面板预览生成的网格结果。
features:
- 可视化 GUI 面板控制网格生成参数
- 支持基础形状(矩形、圆形、菱形)以及自定义多边形生成
- 间距支持利用 width / height 变量进行数学公式计算(如 "width * 0.5")
dependencies:
- 无前置依赖
autorun: false
---
/*
```javascript
*/
var locales = {
zh: {
ui_title: "万能网格生成器",
ui_shape_type: "形状类型",
ui_poly_sides: "多边形边数",
ui_poly_rotation: "多边形旋转 (°)",
ui_rows: "行数",
ui_cols: "列数",
ui_width: "宽度",
ui_height: "高度",
ui_spacing_x: "水平间距",
ui_spacing_y: "垂直间距",
ui_confirm: "确认生成",
notice_success: "网格已生成",
notice_cancelled: "已取消",
type_rect: "矩形 (Rectangle)",
type_diamond: "菱形 (Diamond)",
type_ellipse: "椭圆 (Ellipse)",
type_poly: "多边形 (Polygon)",
type_star: "星形 (Star)"
},
en: {
ui_title: "Universal Grid Generator",
ui_shape_type: "Shape Type",
ui_poly_sides: "Polygon Sides",
ui_poly_rotation: "Polygon Rotation (°)",
ui_rows: "Rows",
ui_cols: "Columns",
ui_width: "Width",
ui_height: "Height",
ui_spacing_x: "Horizontal Spacing",
ui_spacing_y: "Vertical Spacing",
ui_confirm: "Confirm Generate",
notice_success: "Grid generated",
notice_cancelled: "Cancelled",
type_rect: "Rectangle",
type_diamond: "Diamond",
type_ellipse: "Ellipse",
type_poly: "Polygon",
type_star: "Star"
}
};
const { Notice } = ea.obsidian;
const origin = ea.getViewLastPointerPosition();
const startX = origin.x;
const startY = origin.y;
let settings = {
type: "rect",
rows: 4, cols: 4,
width: 100, height: 100,
spacingX: "width", spacingY: "height",
polySides: 3, polyRotation: 0
};
let previewIDs = [];
// 【安全修复】拦截非法字符,防止利用 new Function 进行代码注入报错
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; }
}
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();
ea.style.strokeWidth = 1;
ea.style.strokeColor = "#1e1e1e";
ea.style.strokeStyle = "solid";
ea.style.fillStyle = "solid";
ea.style.backgroundColor = "transparent";
ea.style.roughness = 0;
const { type, rows, cols, width, height, spacingX, spacingY, polySides, polyRotation } = settings;
const realSpacingX = parseDimension(spacingX, width, height);
const realSpacingY = parseDimension(spacingY, width, height);
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
let x = startX + j * (width + realSpacingX);
let y = startY + i * (height + realSpacingY);
let id;
if (type === "polygon") {
const cx = x + width / 2, cy = y + height / 2;
const rx = width / 2, ry = height / 2;
const points = [];
const startAngle = -Math.PI / 2 + (polyRotation * Math.PI / 180);
const step = (2 * Math.PI) / polySides;
for (let k = 0; k < polySides; k++) {
const theta = startAngle + k * step;
points.push([cx + rx * Math.cos(theta), cy + ry * Math.sin(theta)]);
}
points.push(points[0]);
id = ea.addLine(points);
const el = ea.getElement(id);
if (el) { el.polygon = true; el.type = "line"; }
} else {
switch (type) {
case "ellipse": id = ea.addEllipse(x, y, width, height); break;
case "diamond": id = ea.addDiamond(x, y, width, height); break;
case "rect":
default: id = ea.addRect(x, y, width, height); break;
}
}
if(id) previewIDs.push(id);
}
}
await ea.addElementsToView(false, !isPreview, false);
}
function createFloatingPanel() {
if (document.getElementById("ea-grid-v3-panel")) return;
const panelWidth = 320;
const panelHeight = 350;
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-v3-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; align-items: center;`;
const createInput = (label, key, type="number", extraOptions=[]) => {
const wrapper = document.createElement('div');
if (key === "polySides" || key === "polyRotation") wrapper.className = "poly-only";
wrapper.innerHTML = ``;
let input;
if (type === "select") {
input = document.createElement('select');
input.style.cssText = "width:100%; background:var(--background-primary); color:var(--text-normal); border:1px solid var(--background-modifier-border); padding:2px;";
extraOptions.forEach(opt => {
const o = document.createElement('option');
o.text = opt.text; o.value = opt.value;
if(settings[key] === opt.value) o.selected = true;
input.appendChild(o);
});
} else {
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;
if (key === "type") updateVisibility();
runDraw(true);
};
wrapper.appendChild(input);
content.appendChild(wrapper);
return wrapper;
};
const typeWrapper = createInput(t("ui_shape_type"), "type", "select", [
{text: t("type_rect"), value: "rect"}, {text: t("type_ellipse"), value: "ellipse"},
{text: t("type_diamond"), value: "diamond"}, {text: t("type_poly"), value: "polygon"}
]);
typeWrapper.style.gridColumn = "1 / -1";
createInput(t("ui_poly_sides"), "polySides");
createInput(t("ui_poly_rotation"), "polyRotation");
createInput(t("ui_rows"), "rows"); createInput(t("ui_cols"), "cols");
createInput(t("ui_width"), "width"); createInput(t("ui_height"), "height");
createInput(t("ui_spacing_x"), "spacingX", "text"); createInput(t("ui_spacing_y"), "spacingY", "text");
panel.appendChild(content);
const updateVisibility = () => {
const isPoly = settings.type === "polygon";
panel.querySelectorAll('.poly-only').forEach(el => el.style.display = isPoly ? "block" : "none");
};
updateVisibility();
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-v3-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();