---
name: 终极逐笔绘制动画
description: 完美模拟手绘与打字过程。提供可拖拽的悬浮面板,支持滑动条与输入框双向绑定,方便实时调试参数。
author: ymjr
version: 1.2.1
license: MIT
usage: 运行脚本弹出控制台。面板可任意拖拽,不遮挡画布。可反复点击“运行”进行调试。
features:
- 可拖拽的悬浮 UI 控制台,沉浸式调试不打断工作流
- 参数支持 Slider + Number Input 双向绑定同步
- 支持后台反复重启运行,无需反复开关面板
- 动态克隆文字绕过渲染缓存,并传递原生 Canvas 绘制所需的坐标偏移
- 支持全局动画与仅选中元素动画切换
- 修复文字动画重叠问题,打字机效果独立排期
- 可选按 frame / slideshow 路径自动平滑移动视角
dependencies:
- EA_Core Hook 系统
autorun: false
---
/*
```javascript
*/
var locales = {
zh: {
ui_title: "🎬 逐笔动画控制台",
ui_target: "动画目标",
target_all: "全部元素",
target_selected: "仅选中元素",
ui_shape: "几何图形耗时",
ui_freedraw: "自由画笔耗时",
ui_text: "文字耗时",
ui_text_mode_perchar: "单字",
ui_text_mode_total: "总计",
ui_gap: "出场间隔率",
ui_slide_navigation: "🖼️ Slide 视角联动",
ui_slide_hint: "进入下一个 frame/slide 前先移动视角,再播放元素动画",
ui_hide_on_run: "🎥 录屏模式 (运行后自动隐藏面板)",
ui_hide_hint: "提示:面板隐藏期间,再次点击工具栏的脚本图标即可强行停止动画。",
btn_run: "▶️ 运行",
btn_pause: "⏸️ 暂停",
btn_resume: "▶️ 继续",
btn_stop: "⏹️ 停止",
btn_save: "💾 保存配置",
btn_close: "❌ 关闭面板",
notice_start: "🎬 开始播放逐笔绘制动画...",
notice_hidden: "🎥 录屏模式已开启,面板已隐藏",
notice_stop: "⏹️ 动画已手动停止",
notice_finish: "✅ 绘制动画播放完成!",
notice_no_elements: "⚠️ 没有找到可播放动画的元素",
notice_saved: "💾 配置已固化到硬盘",
log_no_core: "⚠️ 未检测到 EA_Core 核心引擎!"
},
en: {
ui_title: "🎬 Animation Console",
ui_target: "Animation Target",
target_all: "All Elements",
target_selected: "Selected Only",
ui_shape: "Shape Duration",
ui_freedraw: "Freedraw Duration",
ui_text: "Text Time",
ui_text_mode_perchar: "Per-char",
ui_text_mode_total: "Total",
ui_gap: "Gap Ratio",
ui_slide_navigation: "🖼️ Slide navigation",
ui_slide_hint: "Move to the next frame/slide before animating its elements",
ui_hide_on_run: "🎥 Rec Mode (Auto-hide panel on run)",
ui_hide_hint: "Hint: Click the script icon in the toolbar again to force stop.",
btn_run: "▶️ Run",
btn_pause: "⏸️ Pause",
btn_resume: "▶️ Resume",
btn_stop: "⏹️ Stop",
btn_save: "💾 Save",
btn_close: "❌ Close",
notice_start: "🎬 Playing animation...",
notice_hidden: "🎥 Panel hidden for recording",
notice_stop: "⏹️ Animation stopped",
notice_finish: "✅ Animation complete!",
notice_no_elements: "⚠️ No elements found to animate",
notice_saved: "💾 Settings saved to disk",
log_no_core: "⚠️ EA_Core not detected!"
}
};
const SCRIPT_ID = "ymjr.feature.progressive-drawing";
const PANEL_ID = "ymjr-animator-panel";
const api = ea.getExcalidrawAPI();
const core = window.EA_Core;
if (!core) {
new Notice(t("log_no_core"));
return;
}
// ==========================================
// 1. 状态管理 & 动画引擎隔离
// ==========================================
if (!ea.plugin._ymjr_animator) {
ea.plugin._ymjr_animator = {
active: false,
paused: false,
startTime: 0,
pauseStartTime: 0,
totalPausedDuration: 0,
elements: [],
progressMap: new Map(),
reqId: null
};
}
const state = ea.plugin._ymjr_animator;
const SLIDE_TRANSITION_DURATION = 1000;
const getElementCenter = (element) => ({
x: element.x + element.width / 2,
y: element.y + element.height / 2
});
const pointInRect = (point, rect) =>
point.x >= rect.x1 && point.x <= rect.x2 &&
point.y >= rect.y1 && point.y <= rect.y2;
const getNavigationRect = (rect, appState) => {
const width = Math.max(1, Math.abs(rect.x2 - rect.x1));
const height = Math.max(1, Math.abs(rect.y2 - rect.y1));
const ratioX = appState.width / width;
const ratioY = appState.height / height;
let nextZoom = Math.min(Math.max(ratioX, ratioY), 30);
if (!Number.isFinite(nextZoom) || nextZoom <= 0) nextZoom = appState.zoom.value || 1;
const scaledWidth = width * nextZoom;
const scaledHeight = height * nextZoom;
if (scaledWidth > appState.width || scaledHeight > appState.height) {
nextZoom = Math.min(appState.width / width, appState.height / height);
}
const deltaX = (appState.width / nextZoom - width) / 2;
const deltaY = (appState.height / nextZoom - height) / 2;
const left = Math.min(rect.x1, rect.x2) - deltaX;
const top = Math.min(rect.y1, rect.y2) - deltaY;
return { left, top, nextZoom };
};
const getSlideNavigationModel = (elements) => {
const frames = elements
.filter(el => el.type === "frame" && !el.isDeleted)
.slice()
.sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")));
let slides = frames.map(frame => ({
id: frame.id,
x1: frame.x,
y1: frame.y,
x2: frame.x + frame.width,
y2: frame.y + frame.height
}));
let source = frames.length > 0 ? "frame" : null;
if (slides.length === 0) {
const path = elements.find(el =>
(el.type === "line" || el.type === "arrow") &&
el.customData?.slideshow &&
Array.isArray(el.points) &&
el.points.length >= 2
);
if (path) {
slides = [];
for (let i = 0; i + 1 < path.points.length; i += 2) {
const pointA = path.points[i];
const pointB = path.points[i + 1];
slides.push({
id: `${path.id}:${i / 2}`,
x1: path.x + pointA[0],
y1: path.y + pointA[1],
x2: path.x + pointB[0],
y2: path.y + pointB[1]
});
}
source = slides.length > 0 ? "slideshow-path" : null;
}
}
if (slides.length === 0) return null;
const getElementSlide = (element) => {
if (element.frameId) {
const direct = slides.findIndex(slide => slide.id === element.frameId);
if (direct >= 0) return direct;
}
const center = getElementCenter(element);
const contained = slides.findIndex(slide => pointInRect(center, slide));
if (contained >= 0) return contained;
return null;
};
return { slides, source, getElementSlide };
};
const getCurrentSlideIndex = (slides, appState) => {
if (!slides?.length) return null;
const zoom = appState.zoom?.value || 1;
const center = {
x: -appState.scrollX + appState.width / (2 * zoom),
y: -appState.scrollY + appState.height / (2 * zoom)
};
const contained = slides.findIndex(slide => pointInRect(center, slide));
if (contained >= 0) return contained;
let closest = 0;
let distance = Infinity;
slides.forEach((slide, index) => {
const slideCenter = {
x: (slide.x1 + slide.x2) / 2,
y: (slide.y1 + slide.y2) / 2
};
const currentDistance = (slideCenter.x - center.x) ** 2 + (slideCenter.y - center.y) ** 2;
if (currentDistance < distance) {
distance = currentDistance;
closest = index;
}
});
return closest;
};
// ==========================================
// 2. UI 按钮更新与清理逻辑
// ==========================================
const updatePlaybackUI = () => {
const panelNode = document.getElementById(PANEL_ID);
if (!panelNode) return;
const btnPause = panelNode.querySelector("#btn-pause");
const btnStop = panelNode.querySelector("#btn-stop");
if (state.active) {
btnStop.disabled = false;
btnStop.style.opacity = "1";
btnPause.disabled = false;
btnPause.style.opacity = "1";
if (state.paused) {
btnPause.innerHTML = t("btn_resume");
btnPause.style.background = "var(--interactive-accent, #007aff)";
btnPause.style.color = "white";
} else {
btnPause.innerHTML = t("btn_pause");
btnPause.style.background = "var(--background-secondary, #eee)";
btnPause.style.color = "var(--text-normal, #333)";
}
} else {
btnStop.disabled = true;
btnStop.style.opacity = "0.5";
btnPause.disabled = true;
btnPause.style.opacity = "0.5";
btnPause.innerHTML = t("btn_pause");
btnPause.style.background = "var(--background-secondary, #eee)";
btnPause.style.color = "var(--text-normal, #333)";
}
};
const stopAnimation = (msg, silent = false) => {
state.active = false;
state.paused = false;
if (state.reqId) cancelAnimationFrame(state.reqId);
core.unregisterHook(SCRIPT_ID + '_render');
state.timeline = [];
api.updateScene({ appState: { shouldCacheIgnoreZoom: false } });
if (state.elements && state.elements.length > 0) {
state.elements.forEach(el => el.version++);
api.updateScene({ elements: api.getSceneElements() });
}
const panelNode = document.getElementById(PANEL_ID);
if (panelNode) {
// 核心逻辑:如果面板被“录屏模式”隐藏,停止时顺便将其彻底销毁
// 这样用户下一次点击脚本图标就能直接呼出面板,体验丝滑
if (panelNode.dataset.hiddenMode === "true") {
panelNode.remove();
} else {
updatePlaybackUI();
}
}
if (!silent && msg) new Notice(t(msg));
};
// ==========================================
// 3. 动画执行逻辑
// ==========================================
const startAnimation = (config) => {
const elements = config.targetMode === 'selected'
? ea.getViewSelectedElements()
: ea.getViewElements();
if (elements.length === 0) {
new Notice(t("notice_no_elements"));
return;
}
if (state.active) stopAnimation(null, true);
state.active = true;
state.paused = false;
state.totalPausedDuration = 0;
state.elements = elements;
state.progressMap.clear();
state.timeline = [];
state.slideModel = config.slideNavigation
? getSlideNavigationModel(ea.getViewElements())
: null;
let currentTime = 0;
let currentSlideIndex = state.slideModel
? getCurrentSlideIndex(state.slideModel.slides, api.getAppState())
: null;
state.elements.forEach(el => {
const targetSlideIndex = state.slideModel?.getElementSlide(el);
if (
targetSlideIndex !== null &&
targetSlideIndex !== undefined &&
currentSlideIndex !== null &&
currentSlideIndex !== undefined &&
targetSlideIndex !== currentSlideIndex
) {
const navigation = getNavigationRect(
state.slideModel.slides[targetSlideIndex],
api.getAppState()
);
state.timeline.push({
type: "slide",
start: currentTime,
end: currentTime + SLIDE_TRANSITION_DURATION,
targetIndex: targetSlideIndex,
navigation,
from: null
});
currentTime += SLIDE_TRANSITION_DURATION;
currentSlideIndex = targetSlideIndex;
}
let duration = config.shapeDuration;
if (el.type === "freedraw") duration = config.freedrawDuration;
if (el.type === "text") {
duration = config.textDurationMode === "perChar"
? config.textDuration * Math.max(1, el.text?.length || 1)
: config.textDuration;
}
duration = Math.max(0, Number(duration) || 0);
el._animStart = currentTime;
el._animEnd = currentTime + duration;
state.timeline.push({
type: "element",
element: el,
start: el._animStart,
end: el._animEnd
});
currentTime += el.type === "text" ? duration : duration * config.gapRatio;
});
state.totalDuration = currentTime;
state.startTime = performance.now();
core.registerHook(SCRIPT_ID + '_render', 'renderElementBefore', handleRenderBefore, 99);
new Notice(t("notice_start"));
updatePlaybackUI();
state.reqId = requestAnimationFrame(loop);
};
// ==========================================
// 4. 悬浮控制台 UI 构建与交互
// ==========================================
// 若面板已存在,再次点击脚本图标则将其移除并强制停止动画(支持隐藏时的紧急制动)
if (document.getElementById(PANEL_ID)) {
document.getElementById(PANEL_ID).remove();
stopAnimation("notice_stop");
return;
}
let settings = ea.getScriptSettings();
if(!settings["shapeDuration"]) {
settings = {
"shapeDuration": { value: "600" },
"freedrawDuration": { value: "800" },
"textDuration": { value: "50" },
"textDurationMode": { value: "perChar" },
"gapRatio": { value: "0.8" },
"slideNavigation": { value: false },
"hideOnRun": { value: false },
"targetMode": { value: "all" }
};
ea.setScriptSettings(settings);
} else {
if (settings["slideNavigation"] === undefined) settings["slideNavigation"] = { value: false };
if (settings["hideOnRun"] === undefined) settings["hideOnRun"] = { value: false };
}
const isPerChar = settings.textDurationMode?.value !== 'total';
const txtMin = isPerChar ? 10 : 100;
const txtMax = isPerChar ? 500 : 5000;
const txtStep = isPerChar ? 10 : 100;
const buildControlRow = (id, label, val, min, max, step) => `
`;
const panel = document.createElement("div");
panel.id = PANEL_ID;
panel.style.cssText = `
position: fixed; top: 60px; right: 40px; width: 300px;
background: var(--background-primary, #ffffff);
border: 1px solid var(--background-modifier-border, #e3e3e3);
border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.2);
z-index: 99999; font-family: var(--font-interface, sans-serif);
display: flex; flex-direction: column; overflow: hidden;
opacity: 0; transform: scale(0.95); transition: opacity 0.2s, transform 0.2s;
`;
panel.innerHTML = `
${buildControlRow("shape", t("ui_shape") + " (ms)", settings.shapeDuration.value, 100, 5000, 100)}
${buildControlRow("freedraw", t("ui_freedraw") + " (ms)", settings.freedrawDuration.value, 100, 5000, 100)}
${buildControlRow("gap", t("ui_gap") + " (0-1)", settings.gapRatio.value, 0, 1, 0.05)}
`;
document.body.appendChild(panel);
updatePlaybackUI();
setTimeout(() => {
panel.style.opacity = "1";
panel.style.transform = "scale(1)";
}, 10);
const header = panel.querySelector("#drag-header");
let isDragging = false, startX, startY, initialLeft, initialTop;
header.addEventListener("mousedown", (e) => {
isDragging = true;
header.style.cursor = "grabbing";
startX = e.clientX;
startY = e.clientY;
const rect = panel.getBoundingClientRect();
initialLeft = rect.left;
initialTop = rect.top;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
panel.style.left = initialLeft + "px";
panel.style.top = initialTop + "px";
});
document.addEventListener("mousemove", (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
panel.style.left = `${initialLeft + dx}px`;
panel.style.top = `${initialTop + dy}px`;
});
document.addEventListener("mouseup", () => {
isDragging = false;
header.style.cursor = "grab";
});
const bindControls = (id) => {
const inp = panel.querySelector(`#inp-${id}`);
const slider = panel.querySelector(`#slider-${id}`);
inp.addEventListener('input', () => { slider.value = inp.value; });
slider.addEventListener('input', () => { inp.value = slider.value; });
};
["shape", "freedraw", "text", "gap"].forEach(bindControls);
const textModeChk = panel.querySelector("#chk-text-mode");
const textModeLbl = panel.querySelector("#lbl-text-mode");
const inpText = panel.querySelector("#inp-text");
const sliderText = panel.querySelector("#slider-text");
const applyTextModeBounds = (perChar) => {
if (perChar) {
textModeLbl.innerText = t("ui_text_mode_perchar");
inpText.min = 10; inpText.max = 500; inpText.step = 10;
sliderText.min = 10; sliderText.max = 500; sliderText.step = 10;
} else {
textModeLbl.innerText = t("ui_text_mode_total");
inpText.min = 100; inpText.max = 5000; inpText.step = 100;
sliderText.min = 100; sliderText.max = 5000; sliderText.step = 100;
}
};
textModeChk.onchange = (e) => {
const perChar = e.target.checked;
applyTextModeBounds(perChar);
let val = Number(inpText.value);
if (perChar) {
val = Math.max(10, Math.min(500, Math.floor(val / 10)));
} else {
val = Math.max(100, Math.min(5000, val * 10));
}
inpText.value = val;
sliderText.value = val;
};
const getCurrentConfig = () => ({
shapeDuration: Number(panel.querySelector("#inp-shape").value),
freedrawDuration: Number(panel.querySelector("#inp-freedraw").value),
textDuration: Number(panel.querySelector("#inp-text").value),
textDurationMode: panel.querySelector("#chk-text-mode").checked ? 'perChar' : 'total',
slideNavigation: panel.querySelector("#chk-slide-navigation").checked,
hideOnRun: panel.querySelector("#chk-hide").checked,
gapRatio: Number(panel.querySelector("#inp-gap").value),
targetMode: panel.querySelector("#sel-target").value
});
panel.querySelector("#btn-run").onclick = () => {
const config = getCurrentConfig();
if (config.hideOnRun) {
panel.dataset.hiddenMode = "true";
panel.style.opacity = "0";
panel.style.transform = "scale(0.95)";
panel.style.pointerEvents = "none";
setTimeout(() => { panel.style.display = "none"; }, 200);
new Notice(t("notice_hidden"));
} else {
panel.dataset.hiddenMode = "false";
}
startAnimation(config);
};
panel.querySelector("#btn-pause").onclick = () => {
if (!state.active) return;
if (state.paused) {
state.paused = false;
state.totalPausedDuration += performance.now() - state.pauseStartTime;
} else {
state.paused = true;
state.pauseStartTime = performance.now();
}
updatePlaybackUI();
};
panel.querySelector("#btn-stop").onclick = () => stopAnimation("notice_stop");
panel.querySelector("#btn-save").onclick = () => {
const config = getCurrentConfig();
settings["shapeDuration"].value = String(config.shapeDuration);
settings["freedrawDuration"].value = String(config.freedrawDuration);
settings["textDuration"].value = String(config.textDuration);
settings["textDurationMode"] = { value: config.textDurationMode };
settings["slideNavigation"] = { value: config.slideNavigation };
settings["hideOnRun"] = { value: config.hideOnRun };
settings["gapRatio"].value = String(config.gapRatio);
settings["targetMode"] = { value: config.targetMode };
ea.setScriptSettings(settings);
new Notice(t("notice_saved"));
};
const closePanel = (doStop = true) => {
panel.style.opacity = "0";
panel.style.transform = "scale(0.95)";
panel.style.pointerEvents = "none";
setTimeout(() => { if(panel.parentNode) panel.remove(); }, 200);
if(doStop && state.active) stopAnimation("notice_stop");
};
panel.querySelector("#btn-close").onclick = () => closePanel(true);
// ==========================================
// 5. 渲染核心工具集
// ==========================================
const drawProgressiveCode = (element, context, progress, payload) => {
const codeMap = window.ExcalidrawAutomate?.plugin?._ymjr_codeMap;
if (!codeMap) return false;
const cache = codeMap.get(element.id);
if (!cache || !cache.image || !cache.image.complete) return false;
const { appState, getElementAbsoluteCoords, allElementsMap } = payload;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
const cx = (x1 + x2) / 2 + appState.scrollX;
const cy = (y1 + y2) / 2 + appState.scrollY;
let shiftX = (x2 - x1) / 2 - (element.x - x1);
let shiftY = (y2 - y1) / 2 - (element.y - y1);
context.save();
context.translate(cx, cy);
context.rotate(element.angle);
context.translate(-shiftX, -shiftY);
const styleTheme = element.customData?.codeHighlight?.style || 'atom-one-dark';
context.fillStyle = styleTheme.includes('light') ? '#fafafa' : '#282c34';
context.fillRect(-10, -10, element.width + 20, element.height + 20);
const textLength = element.text.length;
const visibleChars = Math.floor(textLength * progress);
const lines = element.text.split('\n');
let charsPassed = 0;
let currentLineIdx = 0;
let charsInCurrentLine = 0;
for (let i = 0; i < lines.length; i++) {
const lineLen = lines[i].length + 1;
if (charsPassed + lineLen > visibleChars) {
currentLineIdx = i;
charsInCurrentLine = Math.max(0, visibleChars - charsPassed);
break;
}
charsPassed += lineLen;
if (i === lines.length - 1) {
currentLineIdx = i;
charsInCurrentLine = lines[i].length;
}
}
const getVisualLen = (str) => {
let len = 0;
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
if (code === 9) len += 4;
else if (code > 255) len += 2;
else len += 1;
}
return len;
};
const visualMaxLineLen = Math.max(1, ...lines.map(getVisualLen));
const visualCurrentLen = getVisualLen(lines[currentLineIdx].substring(0, charsInCurrentLine));
const currentLineRatio = visualCurrentLen / visualMaxLineLen;
const lineHeight = element.height / lines.length;
context.beginPath();
const currentY = currentLineIdx * lineHeight;
if (currentLineIdx > 0) {
context.rect(-10, -10, element.width + 20, 10 + currentY + 1);
}
const currentMaskWidth = 10 + (element.width * currentLineRatio) + 15;
const currentH = currentLineIdx === 0 ? (lineHeight + 10) : (lineHeight + 2);
const startY = currentLineIdx === 0 ? -10 : (currentY - 1);
context.rect(-10, startY, currentMaskWidth, currentH);
context.clip();
context.drawImage(cache.image, -10, -10, element.width + 20, element.height + 20);
context.restore();
return true;
};
const drawProgressiveRoughShape = (element, rc, renderConfig, progress) => {
let shapeOrShapes = api.ShapeCache.generateElementShape(element, renderConfig);
if (!shapeOrShapes) return;
const shapes = Array.isArray(shapeOrShapes) ? shapeOrShapes : [shapeOrShapes];
shapes.forEach(drawable => {
if (!drawable || !drawable.sets) { rc.draw(drawable); return; }
const slicedSets = drawable.sets.map(set => {
if (set.type === 'path') {
let p = Math.min(1, progress / 0.7);
return { ...set, ops: set.ops.slice(0, Math.max(1, Math.floor(set.ops.length * p))) };
} else {
let p = Math.max(0, (progress - 0.7) / 0.3);
return { ...set, ops: set.ops.slice(0, Math.max(0, Math.floor(set.ops.length * p))) };
}
});
rc.draw({ ...drawable, sets: slicedSets });
});
};
const drawProgressiveFreedraw = (element, context, renderConfig, progress, payload) => {
const { allElementsMap, appState, getElementAbsoluteCoords } = payload;
const keepPoints = Math.max(2, Math.floor(element.points.length * progress));
const fakeElement = {
...element, id: element.id + "_f",
points: element.points.slice(0, keepPoints),
pressures: element.pressures ? element.pressures.slice(0, keepPoints) : []
};
const shapes = api.ShapeCache.generateElementShape(fakeElement, renderConfig) || [];
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
const cx = (x1 + x2) / 2 + appState.scrollX;
const cy = (y1 + y2) / 2 + appState.scrollY;
let shiftX = (x2 - x1) / 2 - (element.x - x1);
let shiftY = (y2 - y1) / 2 - (element.y - y1);
context.save();
context.translate(cx, cy);
context.rotate(element.angle);
context.translate(-shiftX, -shiftY);
for (const shape of shapes) {
if (typeof shape === "string") {
const path = new Path2D(shape);
const hasOutline = element.customData?.strokeOptions?.hasOutline;
context.fillStyle = hasOutline ? element.backgroundColor : element.strokeColor;
context.fill(path);
if (hasOutline) {
context.lineWidth = element.strokeWidth * (element.customData?.strokeOptions?.outlineWidth ?? 1);
context.strokeStyle = element.strokeColor;
context.stroke(path);
}
} else if (payload.rc) payload.rc.draw(shape);
}
context.restore();
};
const textRenderOffset = { x: 0, y: 0 };
const drawProgressiveText = (element, context, renderConfig, progress, payload) => {
const { allElementsMap, appState, generateElementWithCanvas, drawElementFromCanvas } = payload;
if (!generateElementWithCanvas || !drawElementFromCanvas) return false;
const sourceText = element.text || "";
const visibleChars = Math.floor(sourceText.length * progress);
if (visibleChars === 0) return true;
const visibleString = sourceText.substring(0, visibleChars);
const fakeElement = {
...element,
text: visibleString,
rawText: visibleString,
originalText: visibleString
};
const elementWithCanvas = generateElementWithCanvas(
fakeElement,
allElementsMap,
renderConfig,
appState
);
if (!elementWithCanvas) return false;
// The native cached-canvas renderer requires a scene-space position offset.
drawElementFromCanvas(
elementWithCanvas,
context,
renderConfig,
appState,
allElementsMap,
textRenderOffset
);
return true;
};
const handleRenderBefore = (payload) => {
if (!state.active) return false;
const { element, rc, context, renderConfig, appState, getElementAbsoluteCoords, allElementsMap } = payload;
const progress = state.progressMap.get(element.id);
if (progress === undefined) return true;
if (progress <= 0) {
return true;
}
if (progress >= 1) return false;
if (element.type === "image") { context.globalAlpha = progress; return false; }
if (element.type === "text" && element.customData?.codeHighlight) {
const isDrawn = drawProgressiveCode(element, context, progress, payload);
if (isDrawn) return true;
}
if (element.type === "text") return drawProgressiveText(element, context, renderConfig, progress, payload);
if (element.type === "freedraw") { drawProgressiveFreedraw(element, context, renderConfig, progress, payload); return true; }
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
const cx = (x1 + x2) / 2 + appState.scrollX;
const cy = (y1 + y2) / 2 + appState.scrollY;
let shiftX = (x2 - x1) / 2 - (element.x - x1);
let shiftY = (y2 - y1) / 2 - (element.y - y1);
context.save();
context.translate(cx, cy);
context.rotate(element.angle);
context.translate(-shiftX, -shiftY);
drawProgressiveRoughShape(element, rc, renderConfig, progress);
context.restore();
return true;
};
// ==========================================
// 6. 动画核心循环
// ==========================================
const applySlideTransition = (segment, progress) => {
if (!segment.from) {
const current = api.getAppState();
segment.from = {
scrollX: current.scrollX,
scrollY: current.scrollY,
zoom: current.zoom.value
};
}
const p = Math.max(0, Math.min(1, progress));
const target = segment.navigation;
api.updateScene({
appState: {
scrollX: segment.from.scrollX * (1 - p) - target.left * p,
scrollY: segment.from.scrollY * (1 - p) - target.top * p,
zoom: {
value: segment.from.zoom * (1 - p) + target.nextZoom * p
},
shouldCacheIgnoreZoom: p < 1
}
});
};
const loop = () => {
if (!state.active) return;
if (state.paused) {
state.reqId = requestAnimationFrame(loop);
return;
}
const elapsed = performance.now() - state.startTime - state.totalPausedDuration;
let needsUpdate = false;
state.elements.forEach(el => {
let progress = 0;
if (elapsed >= el._animEnd) progress = 1;
else if (elapsed > el._animStart && el._animEnd > el._animStart) {
progress = (elapsed - el._animStart) / (el._animEnd - el._animStart);
}
if (state.progressMap.get(el.id) !== progress) {
state.progressMap.set(el.id, progress);
needsUpdate = true;
}
});
let activeSlide = null;
for (const segment of state.timeline || []) {
if (segment.type !== "slide") continue;
if (elapsed >= segment.end) {
if (!segment.completed) {
applySlideTransition(segment, 1);
segment.completed = true;
}
} else if (elapsed >= segment.start) {
activeSlide = segment;
break;
}
}
if (activeSlide) {
const progress = (elapsed - activeSlide.start) /
(activeSlide.end - activeSlide.start);
applySlideTransition(activeSlide, progress);
}
if (needsUpdate) api.updateScene({ elements: api.getSceneElements() });
if (elapsed < state.totalDuration + 100) {
state.reqId = requestAnimationFrame(loop);
} else {
stopAnimation("notice_finish");
}
};