314 lines
11 KiB
JavaScript
Raw Normal View History

2026-05-31 13:21:13 +02:00
const CLASS_PREFIX = 'shiki-magic-move';
const CLASS_LEAVE_FROM = `${CLASS_PREFIX}-leave-from`;
const CLASS_LEAVE_TO = `${CLASS_PREFIX}-leave-to`;
const CLASS_LEAVE_ACTIVE = `${CLASS_PREFIX}-leave-active`;
const CLASS_ENTER_FROM = `${CLASS_PREFIX}-enter-from`;
const CLASS_ENTER_TO = `${CLASS_PREFIX}-enter-to`;
const CLASS_ENTER_ACTIVE = `${CLASS_PREFIX}-enter-active`;
const CLASS_MOVE = `${CLASS_PREFIX}-move`;
const CLASS_CONTAINER_RESIZE = `${CLASS_PREFIX}-container-resize`;
const CLASS_CONTAINER_RESTYLE = `${CLASS_PREFIX}-container-restyle`;
const defaultOptions = {
globalScale: 1,
duration: 500,
delayMove: 0.3,
delayLeave: 0.1,
delayEnter: 0.7,
delayContainer: 0.4,
stagger: 0,
easing: 'ease',
animateContainer: true,
containerStyle: true
};
class MagicMoveRenderer {
mapDom = new Map();
previousPromises = [];
isFirstRender = true;
constructor(target, options = {}) {
this.options = {
...defaultOptions,
...options
};
if (typeof target === 'string') this.container = document.querySelector(target);else this.container = target;
// Create anchor
this.anchor = document.createElement('span');
this.anchor.style.position = 'absolute';
this.anchor.style.top = '0';
this.anchor.style.left = '0';
this.anchor.style.height = '1px';
this.anchor.style.width = '1px';
this.container.prepend(this.anchor);
}
applyElementContent(el, token) {
if (token.content !== '\n') {
el.textContent = token.content;
el.classList.add(`${CLASS_PREFIX}-item`);
}
}
applyElementStyle(el, token) {
if (token.htmlStyle) {
if (typeof token.htmlStyle === 'string') {
el.setAttribute('style', token.htmlStyle);
} else {
for (const [key, value] of Object.entries(token.htmlStyle)) {
el.style.setProperty(key, value);
}
}
}
if (token.htmlClass) el.className = [`${CLASS_PREFIX}-item`, token.htmlClass].join(' ');
if (token.color) el.style.color = token.color;
if (token.bgColor) el.style.backgroundColor = token.bgColor;
}
applyElement(el, token) {
this.applyElementContent(el, token);
this.applyElementStyle(el, token);
}
applyNodeStyle(node, step) {
if (step.bg) node.style.backgroundColor = step.bg;
if (step.fg) node.style.color = step.fg;
if (step.rootStyle) {
const items = step.rootStyle.split(';');
for (const item of items) {
const [key, value] = item.split(':');
if (key && value) node.style.setProperty(key.trim(), value.trim());
}
}
}
applyContainerStyle(step) {
if (!this.options.containerStyle) return;
this.applyNodeStyle(this.container, step);
}
registerTransitionEnd(el, cb) {
return () => {
let resolved = false;
let resolve = () => {};
const promise = Promise.race([
// wait for the finish of all animation and transitions on this element then invoke the callback
Promise.allSettled(el.getAnimations().map(animation => animation.finished)).then(() => cb()),
// race it with another promise so it's still resolvable separately
new Promise(_resolve => {
resolve = () => {
if (resolved) return;
resolved = true;
cb();
_resolve();
};
})]);
promise.resolve = resolve;
return promise;
};
}
setCssVariables() {
// Update CSS variables
this.container.style.setProperty('--smm-duration', `${this.options.duration}ms`);
this.container.style.setProperty('--smm-delay-move', `${this.options.delayMove}`);
this.container.style.setProperty('--smm-delay-leave', `${this.options.delayLeave}`);
this.container.style.setProperty('--smm-delay-enter', `${this.options.delayEnter}`);
this.container.style.setProperty('--smm-delay-container', `${this.options.delayContainer}`);
this.container.style.setProperty('--smm-easing', this.options.easing);
this.container.style.setProperty('--smm-stagger', '0');
}
/**
* Replace tokens without animation
*/
replace(step) {
const newDomMap = new Map();
const newChildren = step.tokens.map(token => {
if (this.mapDom.has(token.key)) {
const el = this.mapDom.get(token.key);
this.applyElement(el, token);
newDomMap.set(token.key, el);
this.mapDom.delete(token.key);
return el;
} else {
const el = document.createElement(token.content === '\n' ? 'br' : 'span');
this.applyElement(el, token);
newDomMap.set(token.key, el);
return el;
}
});
this.container.replaceChildren(this.anchor, ...newChildren);
this.applyContainerStyle(step);
this.mapDom = newDomMap;
}
// Note: This function is intentionally not async to keep the operations sync
/**
* Render tokens with animation
*/
render(step) {
this.setCssVariables();
const newDomMap = new Map();
const move = [];
const enter = [];
const leave = [];
const promises = [];
this.previousPromises.forEach(p => p.resolve());
this.previousPromises = [];
// Callbacks to run after forced reflow
const postReflow = [];
const {
globalScale: scale
} = this.options;
// Record the current position of the elements (before rerendering)
const position = new Map();
let anchorRect = this.anchor.getBoundingClientRect();
const containerRect = this.container.getBoundingClientRect();
for (const el of this.mapDom.values()) {
const rect = el.getBoundingClientRect();
position.set(el, {
x: rect.x - anchorRect.x,
y: rect.y - anchorRect.y
});
}
const newChildren = step.tokens.map(token => {
if (this.mapDom.has(token.key)) {
const el = this.mapDom.get(token.key);
this.applyElementContent(el, token);
postReflow.push(() => {
// Update color and style after reflow
this.applyElementStyle(el, token);
});
move.push(el);
newDomMap.set(token.key, el);
this.mapDom.delete(token.key);
return el;
} else {
const el = document.createElement(token.content === '\n' ? 'br' : 'span');
this.applyElement(el, token);
enter.push(el);
newDomMap.set(token.key, el);
return el;
}
});
for (const [_, el] of this.mapDom) {
if (el.tagName === 'BR') continue;
leave.push(el);
}
for (const el of leave) el.style.position = 'absolute';
// Update DOM
this.container.replaceChildren(this.anchor, ...newChildren, ...leave);
this.mapDom = newDomMap;
// Lock leave elements to their position with absolute positioning
leave.forEach((el, idx) => {
el.style.position = 'absolute';
const pos = position.get(el);
el.style.top = `${pos.y / scale}px`;
el.style.left = `${pos.x / scale}px`;
if (this.options.stagger) el.style.setProperty('--smm-stagger', `${idx * this.options.stagger}ms`);else el.style.removeProperty('--smm-stagger');
el.classList.add(CLASS_LEAVE_FROM);
el.classList.add(CLASS_LEAVE_ACTIVE);
postReflow.unshift(() => {
el.classList.remove(CLASS_LEAVE_FROM);
el.classList.add(CLASS_LEAVE_TO);
});
promises.push(this.registerTransitionEnd(el, () => {
el.classList.remove(CLASS_LEAVE_FROM);
el.classList.remove(CLASS_LEAVE_ACTIVE);
el.classList.remove(CLASS_ENTER_TO);
el.remove();
}));
});
// Apply enter animation
if (!this.isFirstRender) {
enter.forEach((el, idx) => {
el.classList.add(CLASS_ENTER_FROM);
el.classList.add(CLASS_ENTER_ACTIVE);
if (this.options.stagger) el.style.setProperty('--smm-stagger', `${idx * this.options.stagger}ms`);else el.style.removeProperty('--smm-stagger');
postReflow.push(() => {
el.classList.remove(CLASS_ENTER_FROM);
el.classList.add(CLASS_ENTER_TO);
});
promises.push(this.registerTransitionEnd(el, () => {
el.classList.remove(CLASS_ENTER_FROM);
el.classList.remove(CLASS_ENTER_ACTIVE);
el.classList.remove(CLASS_ENTER_TO);
}));
});
}
// We recalculate the anchor position because the container might be moved
anchorRect = this.anchor.getBoundingClientRect();
// Set the position of the move elements to the old position
// Set the transition duration to 0ms to make it immediate
move.forEach((el, idx) => {
const newRect = el.getBoundingClientRect();
const newPos = {
x: newRect.x - anchorRect.x,
y: newRect.y - anchorRect.y
};
const oldPos = position.get(el);
el.style.transitionDuration = el.style.transitionDelay = '0ms';
const dx = (oldPos.x - newPos.x) / scale;
const dy = (oldPos.y - newPos.y) / scale;
el.style.transform = `translate(${dx}px, ${dy}px)`;
if (this.options.stagger) el.style.setProperty('--smm-stagger', `${idx * this.options.stagger}ms`);else el.style.removeProperty('--smm-stagger');
postReflow.unshift(() => {
// Remove transform overrides, so it will start animating back to the new position
el.classList.add(CLASS_MOVE);
el.style.transform = el.style.transitionDuration = el.style.transitionDelay = '';
});
promises.push(this.registerTransitionEnd(el, () => {
el.classList.remove(CLASS_MOVE);
}));
});
// Animate the container size
if (this.options.animateContainer && !this.isFirstRender) {
const newRect = this.container.getBoundingClientRect();
if (newRect.width !== containerRect.width || newRect.height !== containerRect.height) {
this.container.style.transitionDuration = this.container.style.transitionDelay = '0ms';
this.container.style.height = `${containerRect.height / scale}px`;
this.container.style.width = `${containerRect.width / scale}px`;
postReflow.unshift(() => {
this.container.classList.add(CLASS_CONTAINER_RESIZE);
this.container.style.transitionDuration = this.container.style.transitionDelay = '';
this.container.style.height = `${newRect.height / scale}px`;
this.container.style.width = `${newRect.width / scale}px`;
});
promises.push(this.registerTransitionEnd(this.container, () => {
this.container.classList.remove(CLASS_CONTAINER_RESIZE);
this.container.style.height = this.container.style.width = '';
}));
}
}
if (this.options.containerStyle) {
if (this.isFirstRender) {
this.applyContainerStyle(step);
} else {
postReflow.push(() => {
this.container.classList.add(CLASS_CONTAINER_RESTYLE);
this.applyContainerStyle(step);
});
promises.push(this.registerTransitionEnd(this.container, () => {
this.container.classList.remove(CLASS_CONTAINER_RESTYLE);
}));
}
}
// Trigger reflow to apply the transform
forceReflow();
postReflow.forEach(cb => cb());
// we need to call the functions that return the promises after the reflow
// to to allow getAnimations to have the correct transitions
const actualPromises = promises.map(promise => promise());
this.isFirstRender = false;
this.previousPromises = actualPromises;
return Promise.all(actualPromises).then();
}
}
// synchronously force layout to put elements into a certain state
function forceReflow() {
return document.body.offsetHeight;
}
export { MagicMoveRenderer, defaultOptions };