314 lines
11 KiB
JavaScript
314 lines
11 KiB
JavaScript
|
|
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 };
|