*/
const SCRIPT_ID = "ymjr.feature.dynamic-library-manager";
var locales = {
zh: {
settings_desc: "个人扩展素材库的所在文件夹路径",
modal_title: "📚 动态素材库管理器",
placeholder_search: "输入名称模糊搜索...",
btn_cancel: "关闭 (Esc)",
btn_unload: "卸载 (Del)",
btn_load: "加载 (Enter)",
empty_list: "未找到匹配的素材库",
tag_loaded: "🟢 已加载",
notice_load_success: "🎉 成功加载: {name}",
notice_unload_success: "🗑️ 成功卸载: {name}",
notice_error: "❌ 操作失败,请检查文件格式"
},
en: {
settings_desc: "Extra Personal library folder path",
modal_title: "📚 Dynamic Library Manager",
placeholder_search: "Type to search...",
btn_cancel: "Close (Esc)",
btn_unload: "Unload (Del)",
btn_load: "Load (Enter)",
empty_list: "No matching library files",
tag_loaded: "🟢 Loaded",
notice_load_success: "🎉 Successfully loaded: {name}",
notice_unload_success: "🗑️ Successfully unloaded: {name}",
notice_error: "❌ Operation failed. Check file format."
}
};
// 1. 初始化命名空间与全局持久化状态
if (!ExcalidrawAutomate.plugin._ymjr_dynLib) {
ExcalidrawAutomate.plugin._ymjr_dynLib = {
isPatched: false,
originalSetLibrary: null,
dynamicItemsMap: new Map() // 【核心】跨窗口持久化存储动态加载的素材项
};
}
function fuzzyMatch(text, query) {
text = text.toLowerCase();
query = query.toLowerCase();
let textIdx = 0;
let queryIdx = 0;
while (textIdx < text.length && queryIdx < query.length) {
if (text[textIdx] === query[queryIdx]) { queryIdx++; }
textIdx++;
}
return queryIdx === query.length;
}
// 2. 核心功能类封装
class DynamicLibraryManager {
constructor(ea, api, app) {
this.ea = ea;
this.api = api;
this.app = app;
this.state = ExcalidrawAutomate.plugin._ymjr_dynLib; // 引用全局状态
this.libraryPath = "";
this.files = [];
}
async init() {
await this.ensureSettings();
this.setupInterceptor();
this.fetchLibraryFiles();
this.renderUI();
}
async ensureSettings() {
let settings = this.ea.getScriptSettings();
if (!settings["extra library path"]) {
settings = {
"extra library path": { value: "Excalidraw/Libraries", description: t("settings_desc") }
};
this.ea.setScriptSettings(settings);
}
this.libraryPath = settings["extra library path"].value.replace(/\\/g, '/');
}
// 拦截器:防止动态注入的库被错误写入持久化配置文件中
setupInterceptor() {
if (!this.state.isPatched) {
this.state.originalSetLibrary = ExcalidrawAutomate.plugin.setStencilLibrary.bind(ExcalidrawAutomate.plugin);
ExcalidrawAutomate.plugin.setStencilLibrary = async (library) => {
try {
const libraryItems = library.libraryItems.filter(item =>
["unpublished", "published"].includes(item.status)
);
const saveLib = { ...library, libraryItems: libraryItems };
return this.state.originalSetLibrary(saveLib);
} catch (e) {
console.error(`[${SCRIPT_ID}] Intercept Error:`, e);
return this.state.originalSetLibrary(library);
}
};
this.state.isPatched = true;
}
}
fetchLibraryFiles() {
let allFiles = this.app.vault.getFiles();
this.files = allFiles.filter(f =>
f.path.startsWith(this.libraryPath) &&
['json', 'excalidrawlib'].includes(f.extension)
);
}
// 核心重组逻辑:合并“永久素材”与“仍在Map中的动态素材”覆写画布内存
async rebuildLibrary() {
// 1. 获取用户本地真正的永久素材库
let permanentItems = [];
const lib2 = ExcalidrawAutomate.plugin.settings.library2;
if (lib2 && lib2.libraryItems) {
permanentItems = lib2.libraryItems.filter(item => ["unpublished", "published"].includes(item.status));
} else if (ExcalidrawAutomate.plugin.settings.library && ExcalidrawAutomate.plugin.settings.library.libraryItems) {
permanentItems = ExcalidrawAutomate.plugin.settings.library.libraryItems.filter(item => ["unpublished", "published"].includes(item.status));
}
// 2. 获取当前会话下所有活跃的动态素材
let dynamicItems = [];
for (const items of this.state.dynamicItemsMap.values()) {
dynamicItems.push(...items);
}
// 3. 组合并强行覆盖画布内存 (实现无痕添加和精准删除)
const assembledItems = [...permanentItems, ...dynamicItems];
this.api.updateLibrary({
libraryItems: assembledItems,
prompt: false,
merge: false, // 必须是 false,才能剔除被删掉的库
openLibraryMenu: true
});
}
async executeLoad(file) {
try {
const content = await this.app.vault.read(file);
const lib = JSON.parse(content);
let libraryItems = lib.libraryItems || lib.library || [];
if (!libraryItems.length) throw new Error("Empty library structure");
libraryItems.forEach(el => el.status = file.basename);
// 存入全局 Map
this.state.dynamicItemsMap.set(file.basename, libraryItems);
await this.rebuildLibrary();
new Notice(t("notice_load_success", { name: file.basename }));
this.renderListUIOnly();
} catch (error) {
console.error(`[${SCRIPT_ID}] Load Error:`, error);
new Notice(t("notice_error"));
}
}
async executeUnload(file) {
if (!this.state.dynamicItemsMap.has(file.basename)) return;
try {
// 从全局 Map 移除
this.state.dynamicItemsMap.delete(file.basename);
await this.rebuildLibrary();
new Notice(t("notice_unload_success", { name: file.basename }));
this.renderListUIOnly();
} catch (error) {
console.error(`[${SCRIPT_ID}] Unload Error:`, error);
new Notice(t("notice_error"));
}
}
renderUI() {
const existingPanel = document.getElementById("ymjr-dynlib-container");
if (existingPanel) existingPanel.remove();
const container = document.createElement("div");
container.id = "ymjr-dynlib-container";
this.container = container;
const style = `
#ymjr-dynlib-container {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(2px);
display: flex; justify-content: center; align-items: flex-start;
padding-top: 10vh; z-index: 9999;
}
.ymjr-modal {
background: var(--background-primary); border: 1px solid var(--background-modifier-border);
border-radius: 8px; box-shadow: var(--shadow-l); width: 450px; max-height: 80vh;
display: flex; flex-direction: column; overflow: hidden; font-family: var(--font-interface);
}
.ymjr-header {
padding: 16px; border-bottom: 1px solid var(--background-modifier-border);
font-size: 1.2em; font-weight: 600; color: var(--text-normal);
}
.ymjr-search-box {
padding: 12px 16px; border-bottom: 1px solid var(--background-modifier-border);
background: var(--background-primary-alt);
}
.ymjr-search-input {
width: 100%; padding: 8px 12px; border-radius: 6px;
background: var(--background-modifier-form-field); border: 1px solid var(--background-modifier-border);
color: var(--text-normal); font-size: 1em; outline: none; transition: box-shadow 0.2s, border-color 0.2s;
}
.ymjr-search-input:focus {
border-color: var(--interactive-accent); box-shadow: 0 0 0 2px var(--background-modifier-active-hover);
}
.ymjr-file-list {
flex-grow: 1; overflow-y: auto; padding: 8px; max-height: 350px; scroll-behavior: smooth;
}
.ymjr-file-item {
padding: 10px 12px; margin: 2px 0; border-radius: 6px; cursor: pointer;
transition: background 0.1s; color: var(--text-normal); border: 1px solid transparent;
display: flex; justify-content: space-between; align-items: center;
}
.ymjr-file-item.selected {
background: var(--interactive-accent); color: var(--text-on-accent); font-weight: 500;
}
.ymjr-tag-loaded {
font-size: 0.8em; padding: 2px 6px; border-radius: 4px;
background: rgba(40, 200, 100, 0.2); color: #28c864; font-weight: bold;
}
.ymjr-file-item.selected .ymjr-tag-loaded { background: rgba(255, 255, 255, 0.2); color: white; }
.ymjr-footer {
padding: 12px 16px; display: flex; justify-content: space-between; align-items: center;
border-top: 1px solid var(--background-modifier-border); background: var(--background-primary);
}
.ymjr-footer-right { display: flex; gap: 8px; }
.ymjr-btn {
padding: 6px 14px; border-radius: 4px; cursor: pointer; border: none; font-weight: 500;
}
.ymjr-btn-cancel { background: transparent; color: var(--text-muted); border: 1px solid var(--background-modifier-border); }
.ymjr-btn-cancel:hover { background: var(--background-modifier-hover); color: var(--text-normal); }
.ymjr-btn-danger { background: rgba(220, 50, 50, 0.1); color: #e63c3c; border: 1px solid rgba(220, 50, 50, 0.3); }
.ymjr-btn-danger:hover { background: rgba(220, 50, 50, 0.2); }
.ymjr-btn-confirm { background: var(--interactive-accent); color: var(--text-on-accent); }
.ymjr-btn-confirm:hover { background: var(--interactive-accent-hover); }
.ymjr-btn:disabled { opacity: 0.4; cursor: not-allowed; }
`;
container.innerHTML = `
<style>${style}</style>
<div class="ymjr-modal">
<div class="ymjr-header">${t("modal_title")}</div>
<div class="ymjr-search-box">
<input type="text" class="ymjr-search-input" placeholder="${t("placeholder_search")}" />
</div>
<div class="ymjr-file-list"></div>
<div class="ymjr-footer">
<button class="ymjr-btn ymjr-btn-cancel">${t("btn_cancel")}</button>
<div class="ymjr-footer-right">
<button class="ymjr-btn ymjr-btn-danger" disabled>${t("btn_unload")}</button>
<button class="ymjr-btn ymjr-btn-confirm" disabled>${t("btn_load")}</button>
</div>
</div>
</div>
`;
document.body.appendChild(container);
this.searchInput = container.querySelector('.ymjr-search-input');
this.listContainer = container.querySelector('.ymjr-file-list');
this.loadBtn = container.querySelector('.ymjr-btn-confirm');
this.unloadBtn = container.querySelector('.ymjr-btn-danger');
const cancelBtn = container.querySelector('.ymjr-btn-cancel');
this.filteredFiles = [...this.files];
this.selectedIndex = 0;
this.renderListUIOnly();
const closeUI = () => container.remove();
// 智能 Toggle
const doToggle = async () => {
if (this.filteredFiles.length === 0 || this.selectedIndex < 0) return;
const selectedFile = this.filteredFiles[this.selectedIndex];
if (this.state.dynamicItemsMap.has(selectedFile.basename)) {
await this.executeUnload(selectedFile);
} else {
await this.executeLoad(selectedFile);
}
};
const doUnload = async () => {
if (this.filteredFiles.length === 0 || this.selectedIndex < 0) return;
await this.executeUnload(this.filteredFiles[this.selectedIndex]);
};
this.searchInput.addEventListener('input', (e) => {
const query = e.target.value;
this.filteredFiles = this.files.filter(f => fuzzyMatch(f.name, query));
this.selectedIndex = this.filteredFiles.length > 0 ? 0 : -1;
this.renderListUIOnly();
});
container.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault(); closeUI();
} else if (e.key === 'Enter') {
e.preventDefault(); doToggle();
} else if (e.key === 'Delete' || e.key === 'Backspace') {
if(!this.unloadBtn.disabled) { e.preventDefault(); doUnload(); }
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (this.selectedIndex < this.filteredFiles.length - 1) {
this.selectedIndex++; this.renderListUIOnly();
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (this.selectedIndex > 0) {
this.selectedIndex--; this.renderListUIOnly();
}
}
});
cancelBtn.addEventListener('click', closeUI);
this.loadBtn.addEventListener('click', doToggle);
this.unloadBtn.addEventListener('click', doUnload);
container.addEventListener('click', (e) => { if (e.target === container) closeUI(); });
setTimeout(() => this.searchInput.focus(), 50);
}
renderListUIOnly() {
if (!this.listContainer) return;
if (this.filteredFiles.length === 0) {
this.listContainer.innerHTML = `<div style="text-align:center; padding: 20px; color: var(--text-muted);">${t("empty_list")}</div>`;
this.loadBtn.disabled = true; this.unloadBtn.disabled = true;
return;
}
this.listContainer.innerHTML = this.filteredFiles.map((f, i) => {
const isSelected = i === this.selectedIndex;
const isLoaded = this.state.dynamicItemsMap.has(f.basename);
const tagHtml = isLoaded ? `<span class="ymjr-tag-loaded">${t("tag_loaded")}</span>` : '';
return `<div class="ymjr-file-item ${isSelected ? 'selected' : ''}" data-index="${i}">
<span>📄 ${f.name}</span>${tagHtml}
</div>`;
}).join('');
if (this.selectedIndex >= 0 && this.selectedIndex < this.filteredFiles.length) {
const currentFile = this.filteredFiles[this.selectedIndex];
const isLoaded = this.state.dynamicItemsMap.has(currentFile.basename);
this.unloadBtn.disabled = !isLoaded;
this.loadBtn.disabled = isLoaded; // 已经加载的项目禁用“加载”按钮,只能点“卸载”
}
const activeEl = this.listContainer.querySelector('.selected');
if (activeEl) activeEl.scrollIntoView({ block: 'nearest' });
this.listContainer.querySelectorAll('.ymjr-file-item').forEach(item => {
item.addEventListener('click', (e) => {
this.selectedIndex = parseInt(e.currentTarget.dataset.index);
this.renderListUIOnly();
});
item.addEventListener('dblclick', async () => {
const selectedFile = this.filteredFiles[this.selectedIndex];
if (this.state.dynamicItemsMap.has(selectedFile.basename)) {
await this.executeUnload(selectedFile);
} else {
await this.executeLoad(selectedFile);
}
});
});
}
}
// 3. 启动应用
(async () => {
const currentApi = ea.getExcalidrawAPI();
const manager = new DynamicLibraryManager(ea, currentApi, app);
await manager.init();
})();