---
name: 生成长方体 (Floating UI)
description: 带有浮动面板和实时预览的长方体生成工具
author: ymjr
version: 1.0.0
license: MIT
usage: 运行脚本后弹出浮动面板,调节参数实时预览,点击确认生成。
features:
- 友好的原生风格浮动UI界面
- 实时预览长方体效果(不污染撤销历史)
- 支持透视虚线显示
- 状态隔离,防止全局变量污染
---
/*
```javascript
*/
var locales = {
zh: {
title: "📦 生成长方体",
width: "宽度 (Width)",
height: "高度 (Height)",
depth: "深度 (Depth)",
angle: "角度 (Angle)",
dotted: "显示透视虚线",
confirm: "确认生成",
cancel: "取消",
notice_success: "✨ 长方体已生成",
notice_cancel: "⭕ 已取消生成"
},
en: {
title: "📦 Generate Cuboid",
width: "Width",
height: "Height",
depth: "Depth",
angle: "Angle",
dotted: "Show Dotted Lines",
confirm: "Confirm",
cancel: "Cancel",
notice_success: "✨ Cuboid generated",
notice_cancel: "⭕ Cancelled"
}
};
const plugin = ExcalidrawAutomate.plugin;
const { Notice } = ea.obsidian;
// 1. 清理旧实例 (防止重复运行产生多个面板)
if (plugin._ymjr_cuboid) {
plugin._ymjr_cuboid.closePanel(false, true);
}
// 2. 初始化独立状态
const origin = ea.getViewLastPointerPosition();
plugin._ymjr_cuboid = {
settings: { width: 100, height: 100, depth: 50, angle: 45, dottedLine: false },
previewIDs: [],
startX: origin.x,
startY: origin.y,
panel: null
};
const state = plugin._ymjr_cuboid;
// 3. 核心绘图逻辑 (支持预览与最终生成)
state.runDraw = async (isPreview = true) => {
const api = ea.getExcalidrawAPI();
if (!api) return;
// 清理旧的预览元素
if (state.previewIDs.length > 0) {
const currentElements = api.getSceneElements();
const nextElements = currentElements.filter(el => !state.previewIDs.includes(el.id));
ea.targetView.updateScene({ elements: nextElements, storeAction: "none" });
state.previewIDs = [];
}
ea.clear();
// 基础样式
ea.style.strokeWidth = 1;
ea.style.strokeColor = ea.style.strokeColor || "black";
ea.style.roughness = 0;
ea.style.backgroundColor = "transparent";
ea.style.fillStyle = "solid";
const { width, height, depth, angle, dottedLine } = state.settings;
const rad = (angle / 180) * Math.PI;
const depthHeight = depth * Math.sin(rad);
const depthWidth = depth * Math.cos(rad);
const sx = state.startX;
const sy = state.startY;
// 绘制正面
state.previewIDs.push(ea.addRect(sx, sy, width, height));
// 绘制顶部
const topPoints = [
[sx, sy], [sx + width, sy],
[sx + width + depthWidth, sy - depthHeight],
[sx + depthWidth, sy - depthHeight], [sx, sy]
];
state.previewIDs.push(ea.addLine(topPoints));
// 绘制右侧
const rightPoints = [
[sx + width, sy], [sx + width, sy + height],
[sx + width + depthWidth, sy + height - depthHeight],
[sx + width + depthWidth, sy - depthHeight], [sx + width, sy]
];
state.previewIDs.push(ea.addLine(rightPoints));
// 绘制虚线 (透视背部)
if (dottedLine) {
ea.style.strokeStyle = "dotted";
const backCorner = [sx + depthWidth, sy + height - depthHeight];
const backTop = [sx + depthWidth, sy - depthHeight];
const backRight = [sx + width + depthWidth, sy + height - depthHeight];
const frontLeftBottom = [sx, sy + height];
state.previewIDs.push(ea.addLine([backTop, backCorner]));
state.previewIDs.push(ea.addLine([backCorner, backRight]));
state.previewIDs.push(ea.addLine([backCorner, frontLeftBottom]));
ea.style.strokeStyle = "solid";
}
ea.addToGroup(state.previewIDs);
// 获取新生成的元素并更新到画布
const newElements = ea.getElements();
state.previewIDs = newElements.map(el => el.id);
const currentElements = api.getSceneElements();
ea.targetView.updateScene({
elements: [...currentElements, ...newElements],
storeAction: isPreview ? "none" : "capture" // 预览时不污染撤销历史
});
};
// 4. 关闭与清理逻辑
state.closePanel = (confirm, isRestart = false) => {
if (state.panel) state.panel.remove();
if (confirm) {
state.runDraw(false); // 最终写入历史记录
new Notice(t("notice_success"));
} else {
// 撤销预览
if (state.previewIDs.length > 0) {
const api = ea.getExcalidrawAPI();
if (api) {
const next = api.getSceneElements().filter(el => !state.previewIDs.includes(el.id));
ea.targetView.updateScene({ elements: next, storeAction: "none" });
}
}
if (!isRestart) new Notice(t("notice_cancel"));
}
if (!isRestart) delete plugin._ymjr_cuboid;
};
// 5. 构建原生风格的浮动 UI
function createFloatingPanel() {
const panel = document.createElement('div');
state.panel = panel;
// 使用 Obsidian 原生 CSS 变量以适配主题
panel.style.cssText = `
position: absolute; top: 80px; left: 80px; width: 260px;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
border-radius: 8px; z-index: 9999;
display: flex; flex-direction: column;
font-family: var(--font-interface);
color: var(--text-normal);
user-select: none;
`;
// 标题栏 (可拖拽)
const header = document.createElement('div');
header.style.cssText = `
padding: 12px 16px; background: var(--background-secondary);
cursor: grab; border-bottom: 1px solid var(--background-modifier-border);
display: flex; justify-content: space-between; align-items: center;
border-radius: 8px 8px 0 0; font-weight: 600; font-size: 14px;
`;
header.innerHTML = `${t("title")}`;
const closeBtn = document.createElement('button');
closeBtn.innerHTML = "✕";
closeBtn.style.cssText = "background:none; border:none; cursor:pointer; color:var(--text-muted); padding:0;";
closeBtn.onclick = () => state.closePanel(false);
header.appendChild(closeBtn);
panel.appendChild(header);
// 拖拽逻辑
let isDragging = false, startX, startY, initLeft, initTop;
header.onmousedown = (e) => {
if(e.target.tagName === 'BUTTON') return;
isDragging = true; header.style.cursor = 'grabbing';
startX = e.clientX; startY = e.clientY;
const rect = panel.getBoundingClientRect();
initLeft = rect.left; initTop = rect.top;
e.preventDefault();
};
document.addEventListener('mousemove', (e) => {
if(!isDragging) return;
panel.style.left = (initLeft + e.clientX - startX) + 'px';
panel.style.top = (initTop + e.clientY - startY) + 'px';
});
document.addEventListener('mouseup', () => {
isDragging = false; header.style.cursor = 'grab';
});
// 内容区
const content = document.createElement('div');
content.style.cssText = `padding: 16px; display: flex; flex-direction: column; gap: 12px;`;
const createInput = (label, key, type="number") => {
const row = document.createElement('div');
row.style.cssText = "display: flex; justify-content: space-between; align-items: center;";
row.innerHTML = ``;
const input = document.createElement('input');
input.type = type;
if (type === "checkbox") {
input.checked = state.settings[key];
input.onchange = (e) => { state.settings[key] = e.target.checked; state.runDraw(true); };
input.style.cursor = "pointer";
} else {
input.value = state.settings[key];
input.style.cssText = "width: 70px; background: var(--background-modifier-form-field); border: 1px solid var(--background-modifier-border); color: var(--text-normal); border-radius: 4px; padding: 4px 8px; font-size: 13px;";
input.oninput = (e) => { state.settings[key] = Number(e.target.value); state.runDraw(true); };
}
row.appendChild(input);
content.appendChild(row);
};
createInput(t("width"), "width");
createInput(t("height"), "height");
createInput(t("depth"), "depth");
createInput(t("angle"), "angle");
createInput(t("dotted"), "dottedLine", "checkbox");
panel.appendChild(content);
// 底部按钮区
const footer = document.createElement('div');
footer.style.cssText = `
padding: 12px 16px; border-top: 1px solid var(--background-modifier-border);
display: flex; justify-content: flex-end; gap: 8px; background: var(--background-primary-alt);
border-radius: 0 0 8px 8px;
`;
const cancelBtn = document.createElement('button');
cancelBtn.innerText = t("cancel");
cancelBtn.onclick = () => state.closePanel(false);
const confirmBtn = document.createElement('button');
confirmBtn.innerText = t("confirm");
confirmBtn.className = "mod-cta"; // 使用 Obsidian 的强调按钮样式
confirmBtn.onclick = () => state.closePanel(true);
footer.appendChild(cancelBtn);
footer.appendChild(confirmBtn);
panel.appendChild(footer);
// 挂载到 Excalidraw 容器内以确保层级正确
const container = ea.targetView.containerEl.querySelector('.excalidraw-wrapper') || document.body;
container.appendChild(panel);
// 初始渲染预览
state.runDraw(true);
}
createFloatingPanel();