--- name: Callout 核心引擎 description: 后台引擎:拦截并渲染携带 Callout 标记的元素,提供高级容器视觉和内部文本自适应排版。 author: ymjr version: 1.0.0 license: MIT usage: 后台常驻引擎。为所有包含 `customData.callout` 的元素提供底层渲染能力。 features: - 拦截 `drawElementOnCanvas` 自定义绘制带侧边栏和图标的 Callout UI - 拦截 `beforeStartTextEditing` 等钩子实现 Callout 文本向右偏移排版和自适应边界 - 维持 Excalidraw 原生选中框和文本编辑器一致性 dependencies: - 独立运行,供 Callout 系列的 Action 动作脚本调用 autorun: true --- /* ```javascript */ var locales = { zh: { log_unmounted: "[{id}] 🔌 已卸载", log_mounted: "[{id}] 🚀 挂载完毕" }, en: { log_unmounted: "[{id}] 🔌 Unmounted", log_mounted: "[{id}] 🚀 Mounted successfully" } }; const SCRIPT_ID = "ymjr.feature.callout-engine"; // ====================== 静态资源与常量 ====================== const CALLOUT_ICONS_SVG = { warning: ``, note: ``, info: ``, tip: ``, success: ``, failure: ``, cite: ``, quote: ``, example: ``, danger: ``, bug: ``, question: ``, abstract: `` }; const CALLOUT_BG_COLORS = { note: "#DAE9FA", info: "#DAE9FA", tip: "#D9F5F5", success: "#DAF5E5", failure: "#FCE0E4", warning: "#FCEAD9", error: "#FADABD", quote: "#F1F1F1", cite: "#F1F1F1", example: "#EBE5FC", danger: "#FCE0E4", bug: "#FCE0E4", question: "#FCEAD9", abstract: "#D9F5F5" }; const CALLOUT_ICON_COLORS = { note: "#1775D9", info: "#1775D9", tip: "#16A6AB", success: "#1DA51D", failure: "#DD2C38", warning: "#DE7417", error: "#DD2C38", quote: "#9E9E9E", cite: "#9E9E9E", example: "#8F47E1", danger: "#DD2C38", bug: "#DD2C38", question: "#DE7417", abstract: "#16A6AB" }; const CALLOUT_HEADER_HEIGHT = 36; const CALLOUT_STRIP_WIDTH = 6; const hexToRgba = (hex, alpha) => { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); return `rgba(${r}, ${g}, ${b}, ${alpha})`; }; window._calloutIconCache = window._calloutIconCache || new Map(); const getColoredIconImage = (type, color, ea, api) => { const cacheKey = `${type}-${color}`; if (window._calloutIconCache.has(cacheKey)) { return window._calloutIconCache.get(cacheKey); } const svgRaw = CALLOUT_ICONS_SVG[type] || CALLOUT_ICONS_SVG.note; let svgColored = svgRaw; if (svgColored.includes('currentColor')) { svgColored = svgColored.replaceAll('currentColor', color); } else if (svgColored.includes('fill="')) { svgColored = svgColored.replace(/fill="[^"]*"/g, `fill="${color}"`); } else { svgColored = svgColored.replace(' { if (api) api.updateScene({ appState: { ...api.getAppState() } }); }; window._calloutIconCache.set(cacheKey, img); return img; }; // ====================== 图形渲染 ====================== const handleDrawElementOnCanvas = (ctx) => { const { ea, api } = ctx; if (!ea || !api) return; const { element, context } = ctx; if (element.type === "rectangle" && element.customData?.callout) { const calloutData = element.customData.callout; const type = calloutData.type || "note"; const colorType = calloutData.colorType || "solid"; const w = element.width; const h = element.height; const hh = CALLOUT_HEADER_HEIGHT; const sw = CALLOUT_STRIP_WIDTH; const r = (element.roundness && typeof element.roundness.value === 'number') ? element.roundness.value : (element.roundness ? 8 : 0); let mainColor; let headerFill; let bodyFill; const isUserColor = element.strokeColor !== "#000000" && element.strokeColor !== "transparent"; const defaultMainColor = CALLOUT_ICON_COLORS[type] || CALLOUT_ICON_COLORS.note; if (colorType === "solid") { mainColor = isUserColor ? element.strokeColor : defaultMainColor; headerFill = CALLOUT_BG_COLORS[type] || CALLOUT_BG_COLORS.note; bodyFill = "#FFFFFF"; } else { mainColor = isUserColor ? element.strokeColor : defaultMainColor; headerFill = hexToRgba(mainColor, 0.25); bodyFill = hexToRgba(mainColor, 0.1); } context.save(); // 1. Body 背景 context.beginPath(); if (context.roundRect && r > 0) context.roundRect(0, 0, w, h, r); else context.rect(0, 0, w, h); context.fillStyle = bodyFill; context.fill(); context.save(); context.shadowBlur = 0; context.shadowOffsetX = 0; context.shadowOffsetY = 0; context.shadowColor = 'transparent'; // 2. Header 背景 context.beginPath(); if (context.roundRect && r > 0) context.roundRect(0, 0, w, hh, [r, r, 0, 0]); else context.rect(0, 0, w, hh); context.fillStyle = headerFill; context.fill(); // 3. 左侧条 (Strip) context.beginPath(); context.moveTo(sw, 0); context.lineTo(r, 0); context.quadraticCurveTo(0, 0, 0, r); context.lineTo(0, h - r); context.quadraticCurveTo(0, h, r, h); context.lineTo(sw, h); context.lineTo(sw, 0); context.closePath(); context.fillStyle = headerFill; context.fill(); // 4. 绘制图标 const iconSize = 24; const iconPadding = 8; const iconX = sw + iconPadding; const iconY = (hh - iconSize) / 2; const img = getColoredIconImage(type, mainColor, ea, api); if (img.complete && img.naturalWidth > 0) { context.drawImage(img, iconX, iconY, iconSize, iconSize); } // 5. 绘制标题文字 let titleText = calloutData.title || type.charAt(0).toUpperCase() + type.slice(1); if (titleText) { context.font = "bold 16px sans-serif"; context.textAlign = "left"; context.textBaseline = "middle"; context.fillStyle = mainColor; const textX = iconX + iconSize + 6; const textY = hh / 2; context.fillText(titleText, textX, textY); } context.restore(); context.restore(); return true; } return false; }; // ====================== 文本边界 Hooks ====================== const handleBeforeStartTextEditing = (ctx) => { const { ea, api } = ctx; if (!ea || !api) return; if (ctx.container?.customData?.callout) { ctx.verticalAlign = "top"; ctx.textAlign = "left"; // Callout 文本靠左排布 ctx.forceVerticalAlign = true; ctx.forceTextAlign = true; ctx.shouldBindToContainer = true; ctx.shouldResizeContainer = true; } }; const handleComputeBoundTextPosition = (ctx) => { const { ea, api } = ctx; if (!ea || !api) return; if (ctx.container?.customData?.callout) { ctx.verticalAlign = "top"; ctx.textAlign = "left"; ctx.headerOffset = CALLOUT_HEADER_HEIGHT; ctx.stripOffset = 10; // 左侧条宽度 + 间距 } }; const handleComputeContainerDimension = (ctx) => { const { ea, api } = ctx; if (!ea || !api) return; if (ctx.container?.customData?.callout) { ctx.result = Math.max(ctx.dimension + ctx.padding * 2 + CALLOUT_HEADER_HEIGHT, 60); } }; const handleGetBoundTextMaxWidth = (ctx) => { const { ea, api } = ctx; if (!ea || !api) return; if (ctx.container?.customData?.callout) { const stripWidth = 10; ctx.result = ctx.width - ctx.padding * 2 - stripWidth; } }; const handleGetBoundTextMaxHeight = (ctx) => { const { ea, api } = ctx; if (!ea || !api) return; if (ctx.container?.customData?.callout) { ctx.result = ctx.height - ctx.padding * 2 - CALLOUT_HEADER_HEIGHT; } }; // ====================== 挂载系统 ====================== const clearShapeCacheForExtensions = () => { setTimeout(() => { const api = ea.getExcalidrawAPI(); if (!api) return; const elements = api.getSceneElements(); const allElsMap = typeof api.App.getSceneElementsMapIncludingDeleted === "function" ? api.App.getSceneElementsMapIncludingDeleted() : new Map(elements.map(el => [el.id, el])); let hasUpdates = false; const updatedElementsMap = new Map(); for (const curr of elements) { if (curr.customData?.callout) { if (api.ShapeCache) { api.ShapeCache.cache.delete(curr); } if (curr.boundElements) { for (const b of curr.boundElements) { const bEl = allElsMap.get(b.id); if (bEl && bEl.type === "text" && bEl.containerId === curr.id) { const stripOffset = 10; const headerOffset = CALLOUT_HEADER_HEIGHT; const textX = curr.x + stripOffset; const textY = curr.y + headerOffset; let newX = textX; let newY = textY; // 2. 绕容器中心点处理旋转 if (curr.angle && curr.angle !== 0) { const cx = curr.x + curr.width / 2; const cy = curr.y + curr.height / 2; const textCenterX = textX + bEl.width / 2; const textCenterY = textY + bEl.height / 2; // 2D 旋转矩阵 newX = cx + (textCenterX - cx) * Math.cos(curr.angle) - (textCenterY - cy) * Math.sin(curr.angle) - bEl.width / 2; newY = cy + (textCenterX - cx) * Math.sin(curr.angle) + (textCenterY - cy) * Math.cos(curr.angle) - bEl.height / 2; } // 3. 记录将要更新的文本元素状态 updatedElementsMap.set(bEl.id, { ...bEl, x: newX, y: newY, textAlign: "left", verticalAlign: "top" }); hasUpdates = true; } } } } } // 4. 将更新合并回全量数组并静默推送给引擎 if (hasUpdates) { const nextElements = elements.map(el => updatedElementsMap.get(el.id) || el); api.updateScene({ elements: nextElements }); } }, 50); }; function mountFeature() { if (!window.EA_Core) return; if (typeof ExcalidrawAutomate.plugin[`disable_${SCRIPT_ID}`] === "function") { ExcalidrawAutomate.plugin[`disable_${SCRIPT_ID}`](); } const core = window.EA_Core; // 挂载渲染 Hook core.registerHook(SCRIPT_ID, 'drawElementOnCanvas', handleDrawElementOnCanvas, 60); // 挂载文本排版 Hooks core.registerHook(SCRIPT_ID, 'beforeStartTextEditing', handleBeforeStartTextEditing, 60); core.registerHook(SCRIPT_ID, 'computeBoundTextPosition', handleComputeBoundTextPosition, 60); core.registerHook(SCRIPT_ID, 'computeContainerDimension', handleComputeContainerDimension, 60); core.registerHook(SCRIPT_ID, 'getBoundTextMaxWidth', handleGetBoundTextMaxWidth, 60); core.registerHook(SCRIPT_ID, 'getBoundTextMaxHeight', handleGetBoundTextMaxHeight, 60); ExcalidrawAutomate.plugin[`disable_${SCRIPT_ID}`] = () => { if (window.EA_Core) window.EA_Core.unregisterHook(SCRIPT_ID); console.log(t("log_unmounted", { id: SCRIPT_ID })); }; console.log(t("log_mounted", { id: SCRIPT_ID })); clearShapeCacheForExtensions(); } mountFeature();