--- name: GIF 逐帧控制引擎 description: 后台引擎:拦截逗号和句号快捷键,并提供悬浮 UI,实现被标记元素的 GIF 逐帧播放与控制。 author: ymjr version: 1.0.0 license: MIT usage: 后台常驻引擎。选中带有 GIF 标记的元素后,按 , (逗号) 上一帧,按 . (句号) 下一帧。 features: - 增加 try-catch 生命周期保护,避免热重载崩溃 - 拦截 handleCanvasPointerUp 识别选中状态并挂载交互面板 - 拦截 onKeyDown 实现快捷键步进切换 dependencies: - 与 Action-Toggle-GIF 配合使用 autorun: true --- /* ```javascript */ const SCRIPT_ID = "ymjr.feature.gif-engine"; var locales = { zh: { log_unmounted: "[{id}] 🔌 动画引擎已卸载", log_mounted: "[{id}] 🚀 动画引擎挂载完毕", ui_prev: "上一帧 <", ui_next: "下一帧 >", ui_reset: "重置", ui_close: "关闭" }, en: { log_unmounted: "[{id}] 🔌 Engine unmounted", log_mounted: "[{id}] 🚀 Engine mounted", ui_prev: "Prev <", ui_next: "Next >", ui_reset: "Reset", ui_close: "Close" } }; // 状态管理 const getEngineState = () => { if (!ea?.plugin?._ymjr_gifEngine) { ExcalidrawAutomate.plugin._ymjr_gifEngine = { selectedGifEls: [], lastSelectedId: null, uiHidden: false }; } return ExcalidrawAutomate.plugin._ymjr_gifEngine; }; // 更新 GIF 帧索引 const updateGifFrame = async (ea, api, action) => { const state = getEngineState(); if (!state.selectedGifEls || state.selectedGifEls.length === 0) return; ea.clear(); ea.copyViewElementsToEAforEditing(state.selectedGifEls); const eaElements = ea.getElements(); let isChanged = false; eaElements.forEach(el => { if (el.customData?.gif) { const currentIdx = el.customData.gif.index || 0; const maxIdx = el.customData.gif.frameCount > 0 ? el.customData.gif.frameCount - 1 : 9999; let newIdx = currentIdx; if (action === "prev") { newIdx = Math.max(currentIdx - 1, 0); } else if (action === "next") { newIdx = Math.min(currentIdx + 1, maxIdx); } else if (action === "reset") { newIdx = 0; } if (newIdx !== currentIdx) { el.customData.gif.index = newIdx; // 只更新索引 isChanged = true; } } }); if (isChanged) { // addElementsToView 会自增元素 version,天然触发 canvas 同步重绘,且复用内存中的 frames await ea.addElementsToView(false, false, false); renderUI(ea, api); } }; // 销毁 UI (加入保护机制) const destroyUI = () => { try { document.querySelectorAll("#ymjr-gif-engine-ui-container").forEach(el => el.remove()); } catch (e) { console.warn(`[${SCRIPT_ID}] UI清理失败:`, e); } }; // 渲染 UI const renderUI = (ea, api) => { const state = getEngineState(); const containerId = "ymjr-gif-engine-ui-container"; const contentEl = ea.targetView?.contentEl || document.body; let uiContainer = contentEl.querySelector(`#${containerId}`); if (state.selectedGifEls.length === 0 || state.uiHidden) { destroyUI(); return; } if (!uiContainer) { uiContainer = document.createElement("div"); uiContainer.id = containerId; Object.assign(uiContainer.style, { position: "absolute", bottom: "80px", left: "50%", transform: "translateX(-50%)", zIndex: "100", display: "flex", alignItems: "center", gap: "12px", padding: "8px 16px", borderRadius: "20px", background: "var(--background-primary-alt)", border: "1px solid var(--background-modifier-border)", boxShadow: "0 8px 24px rgba(0, 0, 0, 0.12)", backdropFilter: "blur(8px)", fontFamily: "var(--font-interface)", fontSize: "13px", color: "var(--text-normal)", userSelect: "none" }); uiContainer.innerHTML = `
⏪ ${t("ui_prev")}
${t("ui_next")} ⏩
🔄 ${t("ui_reset")}
❌ ${t("ui_close")}
`; const style = document.createElement("style"); style.innerHTML = ` #${containerId} .ymjr-btn:hover { background: var(--background-modifier-hover); color: var(--text-accent); } #${containerId} .ymjr-close:hover { background: var(--background-modifier-error-hover); color: var(--text-error); } #${containerId} .ymjr-btn:active { transform: scale(0.96); } `; uiContainer.appendChild(style); uiContainer.querySelector(".ymjr-prev").onclick = () => updateGifFrame(ea, api, "prev"); uiContainer.querySelector(".ymjr-next").onclick = () => updateGifFrame(ea, api, "next"); uiContainer.querySelector(".ymjr-reset").onclick = () => updateGifFrame(ea, api, "reset"); uiContainer.querySelector(".ymjr-close").onclick = () => { state.uiHidden = true; destroyUI(); }; contentEl.appendChild(uiContainer); } const primaryEl = state.selectedGifEls[0]; if (primaryEl && primaryEl.customData?.gif) { const currentFrame = (primaryEl.customData.gif.index || 0) + 1; const totalFrames = primaryEl.customData.gif.frameCount > 0 ? primaryEl.customData.gif.frameCount : "∞"; uiContainer.querySelector(".ymjr-status").innerText = `${currentFrame} / ${totalFrames}`; } }; const handlePointerUp = (contextPayload) => { const { ea, api } = contextPayload; if (!ea || !api) return; const appState = api.getAppState(); const selectedEls = api.getSceneElements().filter(el => appState.selectedElementIds[el.id]); const gifEls = selectedEls.filter(el => el.customData?.gif); const state = getEngineState(); if (gifEls.length > 0) { state.selectedGifEls = gifEls; if (state.lastSelectedId !== gifEls[0].id) { state.uiHidden = false; } state.lastSelectedId = gifEls[0].id; renderUI(ea, api); } else { state.selectedGifEls = []; state.lastSelectedId = null; destroyUI(); } return false; }; const handleKeyDown = (contextPayload) => { const { ea, api, event } = contextPayload; if (!ea || !api || !event) return; if (event.ctrlKey || event.altKey || event.shiftKey) return; const appState = api.getAppState(); if (appState.editingTextElement) return; if (["Comma", "Period"].includes(event.code)) { const selectedEls = api.getSceneElements().filter(el => appState.selectedElementIds[el.id]); const gifEls = selectedEls.filter(el => el.customData?.gif); if (gifEls.length > 0) { const state = getEngineState(); state.selectedGifEls = gifEls; state.uiHidden = false; if (event.code === "Comma") { updateGifFrame(ea, api, "prev"); } else if (event.code === "Period") { updateGifFrame(ea, api, "next"); } event.preventDefault(); event.stopPropagation(); return false; } } }; // --- 核心挂载逻辑 --- async function mountFeature() { if (!window.EA_Core) return console.warn("EA_Core 未运行"); // 【关键修复点】:使用 try-catch 包裹旧实例的卸载逻辑 // 即使上一次运行的脚本有 bug 导致卸载报错,也不会中断本次新引擎的挂载 if (typeof ExcalidrawAutomate.plugin[`disable_${SCRIPT_ID}`] === "function") { try { ExcalidrawAutomate.plugin[`disable_${SCRIPT_ID}`](); } catch (error) { console.warn(`[${SCRIPT_ID}] 尝试卸载旧引擎时遇到错误 (可能是幽灵钩子遗留),已强制放行:`, error); } } // 重新注册 Hooks window.EA_Core.registerHook(SCRIPT_ID, 'handleCanvasPointerUp', handlePointerUp, 60); window.EA_Core.registerHook(SCRIPT_ID, 'onKeyDown', handleKeyDown, 60); // 定义安全的卸载函数 ExcalidrawAutomate.plugin[`disable_${SCRIPT_ID}`] = () => { if (window.EA_Core) window.EA_Core.unregisterHook(SCRIPT_ID); try { destroyUI(); } catch(e) {} // 静默处理由于 DOM 变动导致的卸载报错 delete ea?.plugin?._ymjr_gifEngine; console.log(t("log_unmounted", { id: SCRIPT_ID })); }; console.log(t("log_mounted", { id: SCRIPT_ID })); } mountFeature();