--- name: 高级标签管理面板 description: 悬浮面板:可视化的标签(Tags)编辑器,支持回车快速添加、点击移除,实时同步到选中元素。 author: ymjr version: 1.0.0 license: MIT usage: 当需要对画布中的元素快速添加、移除或管理自定义标签(tags)时,点击运行唤起悬浮面板。 features: - 提供可拖拽的悬浮标签管理面板 - 支持回车快速添加标签,并实时同步到当前选中元素的 customData 中 - 支持点击快速移除标签,以及一键清除所有选中元素的标签 dependencies: - 无前置依赖 autorun: false --- /* ```javascript */ var locales = { zh: { notice_no_selection: "⚠️ 当前未选中任何元素,您可以先配置标签,随后选中元素将自动应用。", notice_cleared: "🧹 已清除所选元素的标签", panel_title: "🏷️ 标签编辑器", input_placeholder: "输入新标签后按回车...", btn_add: "添加", notice_exists: "⚠️ 标签已存在!", no_tags: "暂无标签...", btn_clear: "清除全部", btn_done: "完成" }, en: { notice_no_selection: "⚠️ No element selected. You can configure tags first, and they will be applied to selected elements automatically.", notice_cleared: "🧹 Cleared tags from selected elements", panel_title: "🏷️ Tag Editor", input_placeholder: "Type a new tag and press Enter...", btn_add: "Add", notice_exists: "⚠️ Tag already exists!", no_tags: "No tags yet...", btn_clear: "Clear All", btn_done: "Done" } }; const { Notice } = ea.obsidian; const api = ea.getExcalidrawAPI(); // ========================================== // 1. 状态管理与初始化 // ========================================== let uiState = { tags: [] }; // 读取当前选中元素的标签状态(以第一个元素为准) const selectedEls = ea.getViewSelectedElements(); if (selectedEls.length > 0 && selectedEls[0]?.customData?.tags) { uiState.tags = [...selectedEls[0].customData.tags]; } else if (selectedEls.length === 0) { new Notice(t("notice_no_selection")); } // ========================================== // 2. 核心逻辑 (实时应用到元素) // ========================================== async function applyTags() { const els = ea.getViewSelectedElements(); if (els.length === 0) return; els.forEach((el) => { if (uiState.tags.length > 0) { el.customData = { ...el.customData, tags: [...uiState.tags] }; } else { // 如果标签列表为空,则清理 customData 中的 tags if (el.customData && el.customData.tags) { delete el.customData.tags; } } }); ea.copyViewElementsToEAforEditing(els); await ea.addElementsToView(false, false, false); } function clearTags() { uiState.tags = []; applyTags(); renderTags(); new Notice(t("notice_cleared")); } // ========================================== // 3. UI 构建与交互 // ========================================== let renderTags; // 提前声明渲染函数以便内部调用 function createTagsPanel() { const PANEL_ID = "ea-tags-adv-panel"; if (document.getElementById(PANEL_ID)) { document.getElementById(PANEL_ID).remove(); // 如果已存在则先移除,确保状态刷新 } const panel = document.createElement('div'); panel.id = PANEL_ID; panel.style.cssText = `position:fixed; top:150px; right:100px; width:300px; background:var(--background-primary); border:1px solid var(--background-modifier-border); box-shadow:0 10px 30px rgba(0,0,0,0.25); border-radius:8px; z-index:9999; display:flex; flex-direction:column;`; // 头部 (支持拖拽) const header = document.createElement('div'); header.style.cssText = `padding:12px 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; border-radius:8px 8px 0 0;`; header.innerHTML = `${t("panel_title")}`; panel.appendChild(header); const content = document.createElement('div'); content.style.cssText = `padding:15px; display:flex; flex-direction:column; gap:12px;`; // 输入区 const inputWrapper = document.createElement('div'); inputWrapper.style.cssText = "display:flex; gap:8px;"; const tagInput = document.createElement('input'); tagInput.type = "text"; tagInput.placeholder = t("input_placeholder"); tagInput.style.cssText = "flex:1; background:var(--background-modifier-form-field); border:1px solid var(--background-modifier-border); border-radius:4px; padding:6px 10px; color:var(--text-normal); font-size:13px;"; const btnAdd = document.createElement('button'); btnAdd.innerText = t("btn_add"); btnAdd.style.cssText = "padding:6px 12px; font-size:13px; background:var(--interactive-accent); color:var(--text-on-accent); border:none; border-radius:4px; cursor:pointer;"; // 添加标签的核心函数 const handleAddTag = () => { const val = tagInput.value.trim(); if (val && !uiState.tags.includes(val)) { uiState.tags.push(val); tagInput.value = ""; renderTags(); applyTags(); } else if (uiState.tags.includes(val)) { new Notice(t("notice_exists")); } }; tagInput.onkeydown = (e) => { if (e.key === "Enter") handleAddTag(); }; btnAdd.onclick = handleAddTag; inputWrapper.appendChild(tagInput); inputWrapper.appendChild(btnAdd); content.appendChild(inputWrapper); // 标签碎片 (Chips) 容器 const tagsContainerWrapper = document.createElement('div'); tagsContainerWrapper.style.cssText = `background:var(--background-secondary-alt); border:1px solid var(--background-modifier-border); border-radius:6px; padding:10px; min-height:80px; max-height:150px; overflow-y:auto;`; const tagsList = document.createElement('div'); tagsList.style.cssText = "display:flex; flex-wrap:wrap; gap:8px;"; tagsContainerWrapper.appendChild(tagsList); content.appendChild(tagsContainerWrapper); // 渲染标签碎片的函数 renderTags = () => { tagsList.innerHTML = ""; if (uiState.tags.length === 0) { tagsList.innerHTML = `${t("no_tags")}`; return; } uiState.tags.forEach((tag, index) => { const chip = document.createElement('div'); chip.style.cssText = "display:inline-flex; align-items:center; gap:6px; padding:4px 10px; background:var(--interactive-normal); border:1px solid var(--background-modifier-border); border-radius:12px; font-size:12px; color:var(--text-normal);"; const textSpan = document.createElement('span'); textSpan.innerText = tag; const delBtn = document.createElement('button'); delBtn.innerHTML = "✕"; delBtn.style.cssText = "background:none; border:none; cursor:pointer; color:var(--text-muted); padding:0; margin:0; font-size:10px; line-height:1;"; delBtn.onclick = () => { uiState.tags.splice(index, 1); renderTags(); applyTags(); }; chip.appendChild(textSpan); chip.appendChild(delBtn); tagsList.appendChild(chip); }); }; renderTags(); // 初始渲染 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:space-between; background:var(--background-secondary); border-radius: 0 0 8px 8px;`; const btnClear = document.createElement('button'); btnClear.innerText = t("btn_clear"); btnClear.style.cssText = "background:none; border:none; color:var(--text-error); cursor:pointer; font-size:13px; padding:4px;"; btnClear.onclick = clearTags; const btnDone = document.createElement('button'); btnDone.innerText = t("btn_done"); btnDone.className = "mod-cta"; btnDone.style.cssText = "padding:4px 16px; font-size:13px; cursor:pointer;"; btnDone.onclick = () => panel.remove(); footer.appendChild(btnClear); footer.appendChild(btnDone); panel.appendChild(footer); document.body.appendChild(panel); // 事件绑定:关闭面板 header.querySelector('#close-btn').onclick = () => panel.remove(); // 拖拽逻辑 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; panel.style.left = (initLeft + e.clientX - startX) + 'px'; panel.style.top = (initTop + e.clientY - startY) + 'px'; }; document.onmouseup = () => { isDragging = false; document.onmousemove = null; document.onmouseup = null; }; }; // 自动聚焦到输入框 setTimeout(() => tagInput.focus(), 100); } createTagsPanel();