feature: vor und nachteile

This commit is contained in:
Hikyu
2026-05-31 13:54:31 +02:00
parent 552a947a1c
commit e3dad17446
23270 changed files with 3024794 additions and 200 deletions
+32
View File
@@ -0,0 +1,32 @@
import { HighlighterGeneric, ThemedToken } from 'shiki/core';
import { KeyedTokensInfo, MagicMoveDifferOptions, MatchedRanges } from './types.mjs';
export { KeyedToken, MagicMoveRenderOptions, Range } from './types.mjs';
import 'diff-match-patch-es';
type ArgumentsType<F extends Function> = F extends (...args: infer A) => any ? A : never;
declare function createMagicMoveMachine(codeToKeyedTokens: (code: string, lineNumbers?: boolean) => KeyedTokensInfo, options?: MagicMoveDifferOptions): {
readonly current: KeyedTokensInfo;
readonly previous: KeyedTokensInfo;
commit: (code: string, override?: MagicMoveDifferOptions) => {
current: KeyedTokensInfo;
previous: KeyedTokensInfo;
};
reset(): void;
};
declare function codeToKeyedTokens<BundledLangKeys extends string, BundledThemeKeys extends string>(highlighter: HighlighterGeneric<BundledLangKeys, BundledThemeKeys>, code: string, options: ArgumentsType<HighlighterGeneric<BundledLangKeys, BundledThemeKeys>['codeToTokens']>[1], lineNumbers?: boolean): KeyedTokensInfo;
declare function toKeyedTokens(code: string, tokens: ThemedToken[][], salt?: string, lineNumbers?: boolean): KeyedTokensInfo;
/**
* Run diff on two sets of tokens,
* and sync the keys from the first set to the second set if those tokens are matched
*/
declare function syncTokenKeys(from: KeyedTokensInfo, to: KeyedTokensInfo, options?: MagicMoveDifferOptions): {
from: KeyedTokensInfo;
to: KeyedTokensInfo;
};
/**
* Find ranges of text matches between two strings
* It uses `diff-match-patch` under the hood
*/
declare function findTextMatches(a: string, b: string, options?: MagicMoveDifferOptions): MatchedRanges[];
export { KeyedTokensInfo, MagicMoveDifferOptions, MatchedRanges, codeToKeyedTokens, createMagicMoveMachine, findTextMatches, syncTokenKeys, toKeyedTokens };
+32
View File
@@ -0,0 +1,32 @@
import { HighlighterGeneric, ThemedToken } from 'shiki/core';
import { KeyedTokensInfo, MagicMoveDifferOptions, MatchedRanges } from './types.js';
export { KeyedToken, MagicMoveRenderOptions, Range } from './types.js';
import 'diff-match-patch-es';
type ArgumentsType<F extends Function> = F extends (...args: infer A) => any ? A : never;
declare function createMagicMoveMachine(codeToKeyedTokens: (code: string, lineNumbers?: boolean) => KeyedTokensInfo, options?: MagicMoveDifferOptions): {
readonly current: KeyedTokensInfo;
readonly previous: KeyedTokensInfo;
commit: (code: string, override?: MagicMoveDifferOptions) => {
current: KeyedTokensInfo;
previous: KeyedTokensInfo;
};
reset(): void;
};
declare function codeToKeyedTokens<BundledLangKeys extends string, BundledThemeKeys extends string>(highlighter: HighlighterGeneric<BundledLangKeys, BundledThemeKeys>, code: string, options: ArgumentsType<HighlighterGeneric<BundledLangKeys, BundledThemeKeys>['codeToTokens']>[1], lineNumbers?: boolean): KeyedTokensInfo;
declare function toKeyedTokens(code: string, tokens: ThemedToken[][], salt?: string, lineNumbers?: boolean): KeyedTokensInfo;
/**
* Run diff on two sets of tokens,
* and sync the keys from the first set to the second set if those tokens are matched
*/
declare function syncTokenKeys(from: KeyedTokensInfo, to: KeyedTokensInfo, options?: MagicMoveDifferOptions): {
from: KeyedTokensInfo;
to: KeyedTokensInfo;
};
/**
* Find ranges of text matches between two strings
* It uses `diff-match-patch` under the hood
*/
declare function findTextMatches(a: string, b: string, options?: MagicMoveDifferOptions): MatchedRanges[];
export { KeyedTokensInfo, MagicMoveDifferOptions, MatchedRanges, codeToKeyedTokens, createMagicMoveMachine, findTextMatches, syncTokenKeys, toKeyedTokens };
+264
View File
@@ -0,0 +1,264 @@
import { diff } from 'diff-match-patch-es';
import { hash } from 'ohash';
// eslint-disable-next-line ts/no-unsafe-function-type
function createMagicMoveMachine(codeToKeyedTokens, options = {}) {
const EMPTY = toKeyedTokens('', []);
let previous = EMPTY;
let current = EMPTY;
function commit(code, override) {
previous = current;
const mergedOptions = override ? {
...options,
...override
} : options;
const newTokens = codeToKeyedTokens(code, mergedOptions.lineNumbers);
({
from: previous,
to: current
} = syncTokenKeys(previous, newTokens, mergedOptions));
return {
current,
previous
};
}
return {
get current() {
return current;
},
get previous() {
return previous;
},
commit,
reset() {
previous = EMPTY;
current = EMPTY;
}
};
}
function codeToKeyedTokens(highlighter, code, options, lineNumbers = false) {
const result = highlighter.codeToTokens(code, options);
return {
...toKeyedTokens(code, result.tokens,
// We put the lang and theme to participate in the hash calculation because they can affect the tokenization
JSON.stringify([options.lang, 'themes' in options ? options.themes : options.theme]), lineNumbers),
bg: result.bg,
fg: result.fg,
rootStyle: result.rootStyle,
themeName: result.themeName,
lang: options.lang
};
}
function toKeyedTokens(code, tokens, salt = '', lineNumbers = false) {
const hash$1 = hash(code + salt);
let lastOffset = 0;
let firstOffset = 0;
const lineNumberDigits = Math.ceil(Math.log10(tokens.length));
const keyed = splitWhitespaceTokens(tokens).flatMap((line, lineIdx) => {
firstOffset = line[0]?.offset || lastOffset;
const lastEl = line[line.length - 1];
if (!lastEl) lastOffset += 1;else lastOffset = lastEl.offset + lastEl.content.length;
const tokens = [...line, {
content: '\n',
offset: lastOffset
}];
if (lineNumbers) {
tokens.unshift({
key: `${hash$1}-ln-${lineIdx + 1}`,
content: `${String(lineIdx + 1).padStart(lineNumberDigits, ' ')} `,
offset: firstOffset,
htmlClass: 'shiki-magic-move-line-number'
});
}
return tokens;
}).map((token, idx) => {
const t = token;
t.key ||= `${hash$1}-${idx}`;
return t;
});
return {
code,
hash: hash$1,
tokens: keyed,
lineNumbers
};
}
function splitWhitespaceTokens(tokens) {
return tokens.map(line => {
return line.flatMap(token => {
if (token.content.match(/^\s+$/)) return token;
// eslint-disable-next-line regexp/no-super-linear-backtracking
const match = token.content.match(/^(\s*)(.*?)(\s*)$/);
if (!match) return token;
const [, leading, content, trailing] = match;
if (!leading && !trailing) return token;
const expanded = [{
...token,
offset: token.offset + leading.length,
content
}];
if (leading) {
expanded.unshift({
content: leading,
offset: token.offset
});
}
if (trailing) {
expanded.push({
content: trailing,
offset: token.offset + leading.length + content.length
});
}
return expanded;
});
});
}
/**
* Split a token into multiple tokens by given offsets.
*
* The offsets are relative to the token, and should be sorted.
*/
function splitToken(token, offsets) {
let lastOffset = 0;
const key = token.key;
let index = 0;
const tokens = [];
function getKey() {
if (index === 0) {
index++;
return key;
}
return `${key}-${index++}`;
}
for (const offset of offsets) {
if (offset > lastOffset) {
tokens.push({
...token,
content: token.content.slice(lastOffset, offset),
offset: token.offset + lastOffset,
key: getKey()
});
}
lastOffset = offset;
}
if (lastOffset < token.content.length) {
tokens.push({
...token,
content: token.content.slice(lastOffset),
offset: token.offset + lastOffset,
key: getKey()
});
}
return tokens;
}
/**
* Split 2D tokens array by given breakpoints.
*/
function splitTokens(tokens, breakpoints) {
const sorted = Array.from(breakpoints instanceof Set ? breakpoints : new Set(breakpoints)).sort((a, b) => a - b);
if (!sorted.length) return tokens;
return tokens.flatMap(token => {
const breakpointsInToken = sorted.filter(i => token.offset < i && i < token.offset + token.content.length).map(i => i - token.offset).sort((a, b) => a - b);
if (!breakpointsInToken.length) return token;
return splitToken(token, breakpointsInToken);
});
}
/**
* Run diff on two sets of tokens,
* and sync the keys from the first set to the second set if those tokens are matched
*/
function syncTokenKeys(from, to, options = {}) {
const {
splitTokens: shouldSplitTokens = false,
enhanceMatching = true
} = options;
// Run the diff and generate matches parts
// In the matched parts, we override the keys with the same key so that the transition group can know they are the same element
const matches = findTextMatches(from.code, to.code, options);
const tokensFrom = shouldSplitTokens ? splitTokens(from.tokens, matches.flatMap(m => m.from)) : from.tokens;
const tokensTo = shouldSplitTokens ? splitTokens(to.tokens, matches.flatMap(m => m.to)) : to.tokens;
const matchedKeys = new Set();
matches.forEach(match => {
const tokensF = tokensFrom.filter(t => t.offset >= match.from[0] && t.offset + t.content.length <= match.from[1]);
const tokensT = tokensTo.filter(t => t.offset >= match.to[0] && t.offset + t.content.length <= match.to[1]);
let idxF = 0;
let idxT = 0;
while (idxF < tokensF.length && idxT < tokensT.length) {
if (!tokensF[idxF] || !tokensT[idxT]) break;
if (tokensF[idxF].content === tokensT[idxT].content) {
// assign the key from the first set to the second set
tokensT[idxT].key = tokensF[idxF].key;
matchedKeys.add(tokensF[idxF].key);
idxF++;
idxT++;
} else if (tokensF[idxF + 1]?.content === tokensT[idxT].content) {
// console.log('Token missing match', tokensF[idxF], undefined)
idxF++;
} else if (tokensF[idxF].content === tokensT[idxT + 1]?.content) {
// console.log('Token missing match', undefined, tokensT[idxT])
idxT++;
} else {
// console.log('Token missing match', tokensF[idxF], tokensT[idxT])
idxF++;
idxT++;
}
}
});
if (enhanceMatching) {
for (const token of tokensFrom) {
if (matchedKeys.has(token.key)) continue;
if (token.content.length < 3 || !token.content.match(/^[\w-]+$/)) continue;
const matched = tokensTo.find(t => t.content === token.content && !matchedKeys.has(t.key));
if (matched) {
matched.key = token.key;
matchedKeys.add(token.key);
}
}
}
return {
from: tokensFrom.length === from.tokens.length ? from : {
...from,
tokens: tokensFrom
},
to: tokensTo.length === to.tokens.length ? to : {
...to,
tokens: tokensTo
}
};
}
/**
* Find ranges of text matches between two strings
* It uses `diff-match-patch` under the hood
*/
function findTextMatches(a, b, options = {}) {
let delta = diff(a, b);
delta = options.diffCleanup?.(delta) || delta;
let aContent = '';
let bContent = '';
const matched = [];
for (const [op, text] of delta) {
if (op === 0) {
matched.push({
from: [aContent.length, aContent.length + text.length],
to: [bContent.length, bContent.length + text.length],
content: text
});
aContent += text;
bContent += text;
} else if (op === -1) {
aContent += text;
} else if (op === 1) {
bContent += text;
}
}
if (aContent !== a || bContent !== b) throw new Error('Content mismatch');
return matched;
}
export { codeToKeyedTokens, createMagicMoveMachine, findTextMatches, syncTokenKeys, toKeyedTokens };
+2
View File
@@ -0,0 +1,2 @@
export { }
+2
View File
@@ -0,0 +1,2 @@
export { }
+3
View File
@@ -0,0 +1,3 @@
(() => {
throw new Error('`shiki-magic-move` does not have main entry point. Use `shiki-magic-move/vue` for Vue or `shiki-magic-move/core` for core logics.');
})();
+48
View File
@@ -0,0 +1,48 @@
import { HighlighterCore } from 'shiki/core';
import { MagicMoveRenderOptions, MagicMoveDifferOptions, KeyedTokensInfo } from './types.mjs';
import * as React from 'react';
import 'diff-match-patch-es';
interface ShikiMagicMoveProps {
highlighter: HighlighterCore;
lang: string;
theme: string;
code: string;
options?: MagicMoveRenderOptions & MagicMoveDifferOptions;
onStart?: () => void;
onEnd?: () => void;
className?: string;
tabindex?: number;
}
declare function ShikiMagicMove(props: ShikiMagicMoveProps): React.JSX.Element;
interface ShikiMagicMovePrecompiledProps {
steps: KeyedTokensInfo[];
step?: number;
animate?: boolean;
options?: MagicMoveRenderOptions & MagicMoveDifferOptions;
onStart?: () => void;
onEnd?: () => void;
}
/**
* Component to render a compiled magic move step,
* Where the tokens can be generated on build time.
*/
declare function ShikiMagicMovePrecompiled({ steps, step, animate, options, onStart, onEnd, }: ShikiMagicMovePrecompiledProps): React.JSX.Element;
interface ShikiMagicMoveRendererProps {
animate?: boolean;
tokens: KeyedTokensInfo;
previous?: KeyedTokensInfo;
options?: MagicMoveRenderOptions;
onStart?: () => void;
onEnd?: () => void;
className?: string;
style?: React.CSSProperties;
}
/**
* A wrapper component to `MagicMoveRenderer`
*/
declare function ShikiMagicMoveRenderer({ animate, tokens, previous, options, onStart, onEnd, className, style, }: ShikiMagicMoveRendererProps): React.JSX.Element;
export { ShikiMagicMove, ShikiMagicMovePrecompiled, ShikiMagicMoveRenderer };
+48
View File
@@ -0,0 +1,48 @@
import { HighlighterCore } from 'shiki/core';
import { MagicMoveRenderOptions, MagicMoveDifferOptions, KeyedTokensInfo } from './types.js';
import * as React from 'react';
import 'diff-match-patch-es';
interface ShikiMagicMoveProps {
highlighter: HighlighterCore;
lang: string;
theme: string;
code: string;
options?: MagicMoveRenderOptions & MagicMoveDifferOptions;
onStart?: () => void;
onEnd?: () => void;
className?: string;
tabindex?: number;
}
declare function ShikiMagicMove(props: ShikiMagicMoveProps): React.JSX.Element;
interface ShikiMagicMovePrecompiledProps {
steps: KeyedTokensInfo[];
step?: number;
animate?: boolean;
options?: MagicMoveRenderOptions & MagicMoveDifferOptions;
onStart?: () => void;
onEnd?: () => void;
}
/**
* Component to render a compiled magic move step,
* Where the tokens can be generated on build time.
*/
declare function ShikiMagicMovePrecompiled({ steps, step, animate, options, onStart, onEnd, }: ShikiMagicMovePrecompiledProps): React.JSX.Element;
interface ShikiMagicMoveRendererProps {
animate?: boolean;
tokens: KeyedTokensInfo;
previous?: KeyedTokensInfo;
options?: MagicMoveRenderOptions;
onStart?: () => void;
onEnd?: () => void;
className?: string;
style?: React.CSSProperties;
}
/**
* A wrapper component to `MagicMoveRenderer`
*/
declare function ShikiMagicMoveRenderer({ animate, tokens, previous, options, onStart, onEnd, className, style, }: ShikiMagicMoveRendererProps): React.JSX.Element;
export { ShikiMagicMove, ShikiMagicMovePrecompiled, ShikiMagicMoveRenderer };
+132
View File
@@ -0,0 +1,132 @@
import * as React from 'react';
import { codeToKeyedTokens, createMagicMoveMachine, syncTokenKeys, toKeyedTokens } from './core.mjs';
import { MagicMoveRenderer } from './renderer.mjs';
import 'diff-match-patch-es';
import 'ohash';
function normalizeCSSProperties(css) {
if (typeof css === 'string') {
const style = {};
css?.split(';').forEach(pair => {
const [key, value] = pair.split(':');
if (key && value) style[key.trim()] = value.trim();
});
return style;
}
return css;
}
/**
* A wrapper component to `MagicMoveRenderer`
*/
function ShikiMagicMoveRenderer({
animate = true,
tokens,
previous,
options,
onStart,
onEnd,
className,
style
}) {
const container = React.useRef(null);
const renderer = React.useRef(null);
const [isMounted, setIsMounted] = React.useState(false);
React.useEffect(() => {
if (!container.current) return;
// Remove previous content
container.current.innerHTML = '';
setIsMounted(true);
renderer.current = new MagicMoveRenderer(container.current);
}, []);
React.useEffect(() => {
async function render() {
if (!renderer.current) return;
Object.assign(renderer.current.options, options);
if (animate) {
if (previous) renderer.current.replace(previous);
onStart?.();
await renderer.current.render(tokens);
onEnd?.();
} else {
renderer.current.replace(tokens);
}
}
render();
// FIXME: we should only re-render when tokens change, but react-hooks rule doesn't allow.
// Try to correct the dependency array if something goes wrong.
}, [tokens]);
return /*#__PURE__*/React.createElement("pre", {
ref: container,
className: `shiki-magic-move-container ${className || ''}`.trim(),
style: style
}, /*#__PURE__*/React.createElement("div", null, isMounted ? undefined : tokens.tokens.map(token => {
if (token.content === '\n') return /*#__PURE__*/React.createElement("br", {
key: token.key
});
return /*#__PURE__*/React.createElement("span", {
style: {
...normalizeCSSProperties(token.htmlStyle),
color: token.color
},
className: ['shiki-magic-move-item', token.htmlClass].filter(Boolean).join(' '),
key: token.key
}, token.content);
})));
}
function ShikiMagicMove(props) {
const codeToTokens = React.useRef((code, lineNumbers) => codeToKeyedTokens(props.highlighter, code, {
lang: props.lang,
theme: props.theme
}, lineNumbers));
const machine = React.useRef(null);
machine.current = machine.current || createMagicMoveMachine((code, lineNumbers) => codeToTokens.current(code, lineNumbers));
const lineNumbers = props.options?.lineNumbers ?? false;
const result = React.useMemo(() => {
if (props.code === machine.current.current.code && props.theme === machine.current.current.themeName && props.lang === machine.current.current.lang && lineNumbers === machine.current.current.lineNumbers) {
return machine.current;
}
return machine.current.commit(props.code, props.options);
}, [props.code, props.options, props.theme, props.lang, lineNumbers]);
return /*#__PURE__*/React.createElement(ShikiMagicMoveRenderer, {
tokens: result.current,
options: props.options,
previous: result.previous,
onStart: props.onStart,
onEnd: props.onEnd,
className: props.className
});
}
const EMPTY = /* @__PURE__ */toKeyedTokens('', []);
/**
* Component to render a compiled magic move step,
* Where the tokens can be generated on build time.
*/
function ShikiMagicMovePrecompiled({
steps,
step = 0,
animate = true,
options,
onStart,
onEnd
}) {
const [previous, setPrevious] = React.useState(EMPTY);
const result = React.useMemo(() => {
const res = syncTokenKeys(previous, steps[Math.min(step, steps.length - 1)], options);
setPrevious(res.to);
return res;
}, [previous, steps, step, options]);
return /*#__PURE__*/React.createElement(ShikiMagicMoveRenderer, {
tokens: result.to,
previous: result.from,
options: options,
animate: animate,
onStart: onStart,
onEnd: onEnd
});
}
export { ShikiMagicMove, ShikiMagicMovePrecompiled, ShikiMagicMoveRenderer };
+31
View File
@@ -0,0 +1,31 @@
import { MagicMoveRenderOptions, KeyedTokensInfo } from './types.mjs';
import 'diff-match-patch-es';
import 'shiki/core';
declare const defaultOptions: Required<MagicMoveRenderOptions>;
declare class MagicMoveRenderer {
private mapDom;
private container;
private anchor;
private previousPromises;
options: Required<MagicMoveRenderOptions>;
private isFirstRender;
constructor(target: HTMLElement | string, options?: MagicMoveRenderOptions);
private applyElementContent;
private applyElementStyle;
private applyElement;
private applyNodeStyle;
private applyContainerStyle;
private registerTransitionEnd;
setCssVariables(): void;
/**
* Replace tokens without animation
*/
replace(step: KeyedTokensInfo): void;
/**
* Render tokens with animation
*/
render(step: KeyedTokensInfo): Promise<void>;
}
export { MagicMoveRenderer, defaultOptions };
+31
View File
@@ -0,0 +1,31 @@
import { MagicMoveRenderOptions, KeyedTokensInfo } from './types.js';
import 'diff-match-patch-es';
import 'shiki/core';
declare const defaultOptions: Required<MagicMoveRenderOptions>;
declare class MagicMoveRenderer {
private mapDom;
private container;
private anchor;
private previousPromises;
options: Required<MagicMoveRenderOptions>;
private isFirstRender;
constructor(target: HTMLElement | string, options?: MagicMoveRenderOptions);
private applyElementContent;
private applyElementStyle;
private applyElement;
private applyNodeStyle;
private applyContainerStyle;
private registerTransitionEnd;
setCssVariables(): void;
/**
* Replace tokens without animation
*/
replace(step: KeyedTokensInfo): void;
/**
* Render tokens with animation
*/
render(step: KeyedTokensInfo): Promise<void>;
}
export { MagicMoveRenderer, defaultOptions };
+313
View File
@@ -0,0 +1,313 @@
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 };
+51
View File
@@ -0,0 +1,51 @@
import * as solid_js from 'solid-js';
import { JSX } from 'solid-js';
import { HighlighterCore } from 'shiki/core';
import { MagicMoveRenderOptions, MagicMoveDifferOptions, KeyedTokensInfo } from './types.mjs';
import 'diff-match-patch-es';
interface ShikiMagicMoveProps {
highlighter: HighlighterCore;
lang: string;
theme: string;
code: string;
options?: MagicMoveRenderOptions & MagicMoveDifferOptions;
onStart?: () => void;
onEnd?: () => void;
class?: string;
tabindex?: number;
}
declare function ShikiMagicMove(props: ShikiMagicMoveProps): solid_js.JSX.Element;
interface ShikiMagicMovePrecompiledProps {
steps: KeyedTokensInfo[];
step?: number;
animate?: boolean;
options?: MagicMoveRenderOptions & MagicMoveDifferOptions;
onStart?: () => void;
onEnd?: () => void;
}
/**
* Component to render a compiled magic move step,
* Where the tokens can be generated on build time.
*/
declare function ShikiMagicMovePrecompiled(props: ShikiMagicMovePrecompiledProps): solid_js.JSX.Element;
/** @jsxImportSource solid-js */
interface ShikiMagicMoveRendererProps {
animate?: boolean;
tokens: KeyedTokensInfo;
previous?: KeyedTokensInfo;
options?: MagicMoveRenderOptions;
onStart?: () => void;
onEnd?: () => void;
class?: string;
style?: JSX.CSSProperties;
}
/**
* A wrapper component to `MagicMoveRenderer`
*/
declare function ShikiMagicMoveRenderer(props: ShikiMagicMoveRendererProps): JSX.Element;
export { ShikiMagicMove, ShikiMagicMovePrecompiled, ShikiMagicMoveRenderer };
+51
View File
@@ -0,0 +1,51 @@
import * as solid_js from 'solid-js';
import { JSX } from 'solid-js';
import { HighlighterCore } from 'shiki/core';
import { MagicMoveRenderOptions, MagicMoveDifferOptions, KeyedTokensInfo } from './types.js';
import 'diff-match-patch-es';
interface ShikiMagicMoveProps {
highlighter: HighlighterCore;
lang: string;
theme: string;
code: string;
options?: MagicMoveRenderOptions & MagicMoveDifferOptions;
onStart?: () => void;
onEnd?: () => void;
class?: string;
tabindex?: number;
}
declare function ShikiMagicMove(props: ShikiMagicMoveProps): solid_js.JSX.Element;
interface ShikiMagicMovePrecompiledProps {
steps: KeyedTokensInfo[];
step?: number;
animate?: boolean;
options?: MagicMoveRenderOptions & MagicMoveDifferOptions;
onStart?: () => void;
onEnd?: () => void;
}
/**
* Component to render a compiled magic move step,
* Where the tokens can be generated on build time.
*/
declare function ShikiMagicMovePrecompiled(props: ShikiMagicMovePrecompiledProps): solid_js.JSX.Element;
/** @jsxImportSource solid-js */
interface ShikiMagicMoveRendererProps {
animate?: boolean;
tokens: KeyedTokensInfo;
previous?: KeyedTokensInfo;
options?: MagicMoveRenderOptions;
onStart?: () => void;
onEnd?: () => void;
class?: string;
style?: JSX.CSSProperties;
}
/**
* A wrapper component to `MagicMoveRenderer`
*/
declare function ShikiMagicMoveRenderer(props: ShikiMagicMoveRendererProps): JSX.Element;
export { ShikiMagicMove, ShikiMagicMovePrecompiled, ShikiMagicMoveRenderer };
+165
View File
@@ -0,0 +1,165 @@
import { insert, memo, effect, style, className, template, use, createComponent } from 'solid-js/web';
import { createSignal, createEffect, createMemo } from 'solid-js';
import { createMagicMoveMachine, codeToKeyedTokens, syncTokenKeys, toKeyedTokens } from './core.mjs';
import { MagicMoveRenderer } from './renderer.mjs';
import 'diff-match-patch-es';
import 'ohash';
function normalizeCSSProperties(css) {
if (typeof css === 'string') {
const style = {};
css?.split(';').forEach(pair => {
const [key, value] = pair.split(':');
if (key && value) style[key.trim()] = value.trim();
});
return style;
}
return css;
}
var _tmpl$ = /*#__PURE__*/template(`<pre><div>`),
_tmpl$2 = /*#__PURE__*/template(`<br>`),
_tmpl$3 = /*#__PURE__*/template(`<span>`);
/**
* A wrapper component to `MagicMoveRenderer`
*/
function ShikiMagicMoveRenderer(props) {
let container;
let renderer;
const [isMounted, setIsMounted] = createSignal(false);
createEffect(() => {
if (!container) return;
// Remove previous content
container.innerHTML = '';
setIsMounted(true);
renderer = new MagicMoveRenderer(container);
});
createEffect(() => {
async function render() {
if (!renderer) return;
Object.assign(renderer.options, props.options);
if (props.animate === undefined || props.animate === true) {
if (props.previous) renderer.replace(props.previous);
props.onStart?.();
await renderer.render(props.tokens);
props.onEnd?.();
} else {
renderer.replace(props.tokens);
}
}
render();
});
return (() => {
var _el$ = _tmpl$(),
_el$2 = _el$.firstChild;
var _ref$ = container;
typeof _ref$ === "function" ? use(_ref$, _el$) : container = _el$;
insert(_el$2, (() => {
var _c$ = memo(() => !!isMounted());
return () => _c$() ? undefined : props.tokens?.tokens.map(token => {
if (token.content === '\n') return _tmpl$2();
return (() => {
var _el$4 = _tmpl$3();
insert(_el$4, () => token.content);
effect(_p$ => {
var _v$3 = {
...normalizeCSSProperties(token.htmlStyle),
color: token.color
},
_v$4 = ['shiki-magic-move-item', token.htmlClass].filter(Boolean).join(' ');
_p$.e = style(_el$4, _v$3, _p$.e);
_v$4 !== _p$.t && className(_el$4, _p$.t = _v$4);
return _p$;
}, {
e: undefined,
t: undefined
});
return _el$4;
})();
});
})());
effect(_p$ => {
var _v$ = `shiki-magic-move-container ${props.class || ''}`.trim(),
_v$2 = props.style;
_v$ !== _p$.e && className(_el$, _p$.e = _v$);
_p$.t = style(_el$, _v$2, _p$.t);
return _p$;
}, {
e: undefined,
t: undefined
});
return _el$;
})();
}
function ShikiMagicMove(props) {
const codeToTokens = (code, lineNumbers) => codeToKeyedTokens(props.highlighter, code, {
lang: props.lang,
theme: props.theme
}, lineNumbers);
const machine = createMagicMoveMachine((code, lineNumbers) => codeToTokens(code, lineNumbers));
const result = createMemo(() => {
const lineNumbers = props.options?.lineNumbers ?? false;
if (props.code === machine.current.code && props.theme === machine.current.themeName && props.lang === machine.current.lang && lineNumbers === machine.current.lineNumbers) {
return machine;
}
return machine.commit(props.code, props.options);
});
return createComponent(ShikiMagicMoveRenderer, {
get tokens() {
return result().current;
},
get options() {
return props.options;
},
get previous() {
return result().previous;
},
get onStart() {
return props.onStart;
},
get onEnd() {
return props.onEnd;
},
get ["class"]() {
return props.class;
}
});
}
const EMPTY = /* @__PURE__ */toKeyedTokens('', []);
/**
* Component to render a compiled magic move step,
* Where the tokens can be generated on build time.
*/
function ShikiMagicMovePrecompiled(props) {
const [previous, setPrevious] = createSignal(EMPTY);
const result = createMemo(() => {
const res = syncTokenKeys(previous(), props.steps[Math.min(props.step ?? 0, props.steps.length - 1)], props.options);
setPrevious(res.to);
return res;
});
return createComponent(ShikiMagicMoveRenderer, {
get tokens() {
return result().to;
},
get previous() {
return result().from;
},
get options() {
return props.options;
},
get animate() {
return props.animate ?? true;
},
get onStart() {
return props.onStart;
},
get onEnd() {
return props.onEnd;
}
});
}
export { ShikiMagicMove, ShikiMagicMovePrecompiled, ShikiMagicMoveRenderer };
+52
View File
@@ -0,0 +1,52 @@
.shiki-magic-move-container {
position: relative;
white-space: pre;
}
.shiki-magic-move-line-number {
opacity: 0.3;
user-select: none;
}
.shiki-magic-move-item {
display: inline-block;
transition: color var(--smm-duration, 0.5s) var(--smm-easing, 'ease');
}
.shiki-magic-move-move {
transition: all var(--smm-duration, 0.5s) var(--smm-easing, 'ease');
}
.shiki-magic-move-enter-active,
.shiki-magic-move-leave-active {
transition: all var(--smm-duration, 0.5s) var(--smm-easing, 'ease');
}
.shiki-magic-move-container-resize,
.shiki-magic-move-container-restyle {
transition: all var(--smm-duration, 0.5s) var(--smm-easing, 'ease');
transition-delay: calc(var(--smm-duration, 0.5s) * var(--smm-delay-container, 1));
}
.shiki-magic-move-move {
transition-delay: calc(calc(var(--smm-duration, 0.5s) * var(--smm-delay-move, 1)) + var(--smm-stagger, 0));
z-index: 1;
}
.shiki-magic-move-enter-active {
transition-delay: calc(calc(var(--smm-duration, 0.5s) * var(--smm-delay-enter, 1)) + var(--smm-stagger, 0));
z-index: 1;
}
.shiki-magic-move-leave-active {
transition-delay: calc(calc(var(--smm-duration, 0.5s) * var(--smm-delay-leave, 1)) + var(--smm-stagger, 0));
}
.shiki-magic-move-enter-from,
.shiki-magic-move-leave-to {
opacity: 0;
}
br.shiki-magic-move-leave-active {
display: none;
}
+1
View File
@@ -0,0 +1 @@
export * from "./svelte/index.mjs"
+1
View File
@@ -0,0 +1 @@
export * from "./svelte/index.mjs"
+1
View File
@@ -0,0 +1 @@
export * from "./svelte/index.mjs"
@@ -0,0 +1,34 @@
<script lang='ts'>
import type { MagicMoveDifferOptions, MagicMoveRenderOptions } from 'shiki-magic-move/types'
import type { HighlighterCore } from 'shiki/core'
import { codeToKeyedTokens, createMagicMoveMachine } from 'shiki-magic-move/core'
import ShikiMagicMoveRenderer from './ShikiMagicMoveRenderer.svelte'
interface ShikiMagicMoveProps {
highlighter: HighlighterCore
lang: string
theme: string
code: string
options?: MagicMoveRenderOptions & MagicMoveDifferOptions
onStart?: () => void
onEnd?: () => void
class?: string
tabindex?: number
}
const { highlighter, lang, theme, code, options, onStart, onEnd, ...props }: ShikiMagicMoveProps = $props()
const machine = createMagicMoveMachine(
code => codeToKeyedTokens(highlighter, code, { lang, theme }, options?.lineNumbers),
options,
)
const result = $derived(machine.commit(code))
</script>
<ShikiMagicMoveRenderer
tokens={result.current}
previous={result.previous}
{options}
{onStart}
{onEnd}
{...props}
/>
@@ -0,0 +1,46 @@
<script lang='ts'>
import type { KeyedTokensInfo, MagicMoveDifferOptions, MagicMoveRenderOptions } from 'shiki-magic-move/types'
import { syncTokenKeys, toKeyedTokens } from 'shiki-magic-move/core'
import ShikiMagicMoveRenderer from './ShikiMagicMoveRenderer.svelte'
interface ShikiMagicMovePrecompileProps {
steps: KeyedTokensInfo[]
step?: number
animate?: boolean
options?: MagicMoveRenderOptions & MagicMoveDifferOptions
onStart?: () => void
onEnd?: () => void
class?: string
}
const {
steps,
step = 0,
animate = true,
options,
onStart,
onEnd,
}: ShikiMagicMovePrecompileProps = $props()
const EMPTY = toKeyedTokens('', [])
let previous = EMPTY
const result = $derived.by(() => {
if (steps.length === 0)
return
const res = syncTokenKeys(previous, steps[Math.min(step, steps.length - 1)], options)
previous = res.to
return res
})
</script>
{#if result}
<ShikiMagicMoveRenderer
tokens={result.to}
previous={result.from}
{options}
{animate}
{onStart}
{onEnd}
/>
{/if}
@@ -0,0 +1,72 @@
<script lang='ts'>
import type { KeyedTokensInfo, MagicMoveRenderOptions } from 'shiki-magic-move/types'
import { MagicMoveRenderer } from 'shiki-magic-move/renderer'
interface ShikiMagicMoveRendererProps {
class?: string
animate?: boolean
tokens: KeyedTokensInfo
previous?: KeyedTokensInfo
options?: MagicMoveRenderOptions
onStart?: () => void
onEnd?: () => void
}
const {
animate = true,
tokens,
previous,
options,
onStart,
onEnd,
...props
}: ShikiMagicMoveRendererProps = $props()
let container: HTMLPreElement
let renderer: MagicMoveRenderer
let isMounted = $state(false)
$effect(() => {
if (!container)
return
container.innerHTML = ''
isMounted = true
renderer = new MagicMoveRenderer(container)
})
$effect(() => {
async function render() {
if (!renderer)
return
Object.assign(renderer.options, options)
if (animate) {
if (previous)
renderer.replace(previous)
onStart?.()
await renderer.render(tokens)
onEnd?.()
}
else {
renderer.replace(tokens)
}
}
render()
})
</script>
<pre bind:this={container} class='shiki-magic-move-container {props?.class}' {...props}>
<!-- render initial tokens for SSR -->
{#if !isMounted}
{#each tokens.tokens as token (token.key)}
{#if token.content === '\n'}
<br />
{/if}
<span
class='shiki-magic-move-item'
style:color={token.color}
style={token.htmlStyle}>
{token.content}
</span>
{/each}
{/if}
</pre>
+4
View File
@@ -0,0 +1,4 @@
import ShikiMagicMove from './ShikiMagicMove.svelte';
import ShikiMagicMovePrecompiled from './ShikiMagicMovePrecompiled.svelte';
import ShikiMagicMoveRenderer from './ShikiMagicMoveRenderer.svelte';
export { ShikiMagicMove, ShikiMagicMovePrecompiled, ShikiMagicMoveRenderer };
+4
View File
@@ -0,0 +1,4 @@
import ShikiMagicMove from './ShikiMagicMove.svelte';
import ShikiMagicMovePrecompiled from './ShikiMagicMovePrecompiled.svelte';
import ShikiMagicMoveRenderer from './ShikiMagicMoveRenderer.svelte';
export { ShikiMagicMove, ShikiMagicMovePrecompiled, ShikiMagicMoveRenderer };
+4
View File
@@ -0,0 +1,4 @@
import ShikiMagicMove from "./ShikiMagicMove.svelte";
import ShikiMagicMovePrecompiled from "./ShikiMagicMovePrecompiled.svelte";
import ShikiMagicMoveRenderer from "./ShikiMagicMoveRenderer.svelte";
export { ShikiMagicMove, ShikiMagicMovePrecompiled, ShikiMagicMoveRenderer };
+105
View File
@@ -0,0 +1,105 @@
import { Diff } from 'diff-match-patch-es';
import { ThemedToken, TokensResult } from 'shiki/core';
type Range = [number, number];
interface MatchedRanges {
from: Range;
to: Range;
content: string;
}
interface KeyedToken extends ThemedToken {
key: string;
htmlClass?: string;
}
interface KeyedTokensInfo extends Pick<TokensResult, 'bg' | 'fg' | 'rootStyle' | 'themeName'> {
code: string;
hash: string;
tokens: KeyedToken[];
lang?: string;
lineNumbers: boolean;
}
interface MagicMoveRenderOptions {
/**
* Duration of the animation in milliseconds
*
* @default 500
*/
duration?: number;
/**
* Ratio of the duration to delay the move animation
*
* @default 0.3
*/
delayMove?: number;
/**
* Ratio of the duration to delay the leave animation
*
* @default 0
*/
delayLeave?: number;
/**
* Ratio of the duration to delay the enter animation
*
* @default 0.7
*/
delayEnter?: number;
/**
* Ratio of the duration to delay the container animation
*
* @default 0.4
*/
delayContainer?: number;
/**
* Easing function
*
* @default 'ease'
*/
easing?: string;
/**
* Scale applies to the container or it's parents.
* Used to calculate the correct positions of the elements.
*
* @default 1
*/
globalScale?: number;
/**
* Apply animation to the container
*/
animateContainer?: boolean;
/**
* Apply styles (color and background) to the container
*
* @default true
*/
containerStyle?: boolean;
/**
* Stagger the animation of the tokens, in milliseconds
*
* @default 0
*/
stagger?: number;
}
interface MagicMoveDifferOptions {
/**
* Show line numbers
*
* @default false
*/
lineNumbers?: boolean;
/**
* Split the tokens at the edge of diff chunks
*
* @default false
*/
splitTokens?: boolean;
/**
* Algorithm to use to cleanup the diff
*/
diffCleanup?: ((diffs: Diff[]) => Diff[] | void);
/**
* Enhance the matching algorithm to match tokens that has same content
*/
enhanceMatching?: boolean;
}
export type { KeyedToken, KeyedTokensInfo, MagicMoveDifferOptions, MagicMoveRenderOptions, MatchedRanges, Range };
+105
View File
@@ -0,0 +1,105 @@
import { Diff } from 'diff-match-patch-es';
import { ThemedToken, TokensResult } from 'shiki/core';
type Range = [number, number];
interface MatchedRanges {
from: Range;
to: Range;
content: string;
}
interface KeyedToken extends ThemedToken {
key: string;
htmlClass?: string;
}
interface KeyedTokensInfo extends Pick<TokensResult, 'bg' | 'fg' | 'rootStyle' | 'themeName'> {
code: string;
hash: string;
tokens: KeyedToken[];
lang?: string;
lineNumbers: boolean;
}
interface MagicMoveRenderOptions {
/**
* Duration of the animation in milliseconds
*
* @default 500
*/
duration?: number;
/**
* Ratio of the duration to delay the move animation
*
* @default 0.3
*/
delayMove?: number;
/**
* Ratio of the duration to delay the leave animation
*
* @default 0
*/
delayLeave?: number;
/**
* Ratio of the duration to delay the enter animation
*
* @default 0.7
*/
delayEnter?: number;
/**
* Ratio of the duration to delay the container animation
*
* @default 0.4
*/
delayContainer?: number;
/**
* Easing function
*
* @default 'ease'
*/
easing?: string;
/**
* Scale applies to the container or it's parents.
* Used to calculate the correct positions of the elements.
*
* @default 1
*/
globalScale?: number;
/**
* Apply animation to the container
*/
animateContainer?: boolean;
/**
* Apply styles (color and background) to the container
*
* @default true
*/
containerStyle?: boolean;
/**
* Stagger the animation of the tokens, in milliseconds
*
* @default 0
*/
stagger?: number;
}
interface MagicMoveDifferOptions {
/**
* Show line numbers
*
* @default false
*/
lineNumbers?: boolean;
/**
* Split the tokens at the edge of diff chunks
*
* @default false
*/
splitTokens?: boolean;
/**
* Algorithm to use to cleanup the diff
*/
diffCleanup?: ((diffs: Diff[]) => Diff[] | void);
/**
* Enhance the matching algorithm to match tokens that has same content
*/
enhanceMatching?: boolean;
}
export type { KeyedToken, KeyedTokensInfo, MagicMoveDifferOptions, MagicMoveRenderOptions, MatchedRanges, Range };
+1
View File
@@ -0,0 +1 @@
+153
View File
@@ -0,0 +1,153 @@
import * as vue from 'vue';
import { PropType, App } from 'vue';
import { HighlighterCore } from 'shiki/core';
import { MagicMoveRenderOptions, MagicMoveDifferOptions, KeyedTokensInfo } from './types.mjs';
import 'diff-match-patch-es';
declare const ShikiMagicMove: vue.DefineComponent<vue.ExtractPropTypes<{
highlighter: {
type: PropType<HighlighterCore>;
required: true;
};
lang: {
type: StringConstructor;
required: true;
};
theme: {
type: StringConstructor;
required: true;
};
code: {
type: StringConstructor;
required: true;
};
options: {
type: PropType<MagicMoveRenderOptions & MagicMoveDifferOptions>;
default: () => {};
};
}>, () => vue.VNode<vue.RendererNode, vue.RendererElement, {
[key: string]: any;
}>, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, ("end" | "start")[], "end" | "start", vue.PublicProps, Readonly<vue.ExtractPropTypes<{
highlighter: {
type: PropType<HighlighterCore>;
required: true;
};
lang: {
type: StringConstructor;
required: true;
};
theme: {
type: StringConstructor;
required: true;
};
code: {
type: StringConstructor;
required: true;
};
options: {
type: PropType<MagicMoveRenderOptions & MagicMoveDifferOptions>;
default: () => {};
};
}>> & Readonly<{
onEnd?: ((...args: any[]) => any) | undefined;
onStart?: ((...args: any[]) => any) | undefined;
}>, {
options: MagicMoveRenderOptions & MagicMoveDifferOptions;
}, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>;
/**
* Component to render a compiled magic move step,
* Where the tokens can be generated on build time.
*/
declare const ShikiMagicMovePrecompiled: vue.DefineComponent<vue.ExtractPropTypes<{
steps: {
type: PropType<KeyedTokensInfo[]>;
required: true;
};
step: {
type: NumberConstructor;
default: number;
};
animate: {
type: BooleanConstructor;
default: boolean;
};
options: {
type: PropType<MagicMoveRenderOptions & MagicMoveDifferOptions>;
default: () => {};
};
}>, () => vue.VNode<vue.RendererNode, vue.RendererElement, {
[key: string]: any;
}>, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, ("end" | "start")[], "end" | "start", vue.PublicProps, Readonly<vue.ExtractPropTypes<{
steps: {
type: PropType<KeyedTokensInfo[]>;
required: true;
};
step: {
type: NumberConstructor;
default: number;
};
animate: {
type: BooleanConstructor;
default: boolean;
};
options: {
type: PropType<MagicMoveRenderOptions & MagicMoveDifferOptions>;
default: () => {};
};
}>> & Readonly<{
onEnd?: ((...args: any[]) => any) | undefined;
onStart?: ((...args: any[]) => any) | undefined;
}>, {
animate: boolean;
options: MagicMoveRenderOptions & MagicMoveDifferOptions;
step: number;
}, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>;
/**
* A wrapper component to `MagicMoveRenderer`
*/
declare const ShikiMagicMoveRenderer: vue.DefineComponent<vue.ExtractPropTypes<{
animate: {
type: BooleanConstructor;
default: boolean;
};
tokens: {
type: PropType<KeyedTokensInfo>;
required: true;
};
previous: {
type: PropType<KeyedTokensInfo>;
required: false;
};
options: {
type: PropType<MagicMoveRenderOptions>;
};
}>, () => vue.VNode<vue.RendererNode, vue.RendererElement, {
[key: string]: any;
}>, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, ("end" | "start")[], "end" | "start", vue.PublicProps, Readonly<vue.ExtractPropTypes<{
animate: {
type: BooleanConstructor;
default: boolean;
};
tokens: {
type: PropType<KeyedTokensInfo>;
required: true;
};
previous: {
type: PropType<KeyedTokensInfo>;
required: false;
};
options: {
type: PropType<MagicMoveRenderOptions>;
};
}>> & Readonly<{
onEnd?: ((...args: any[]) => any) | undefined;
onStart?: ((...args: any[]) => any) | undefined;
}>, {
animate: boolean;
}, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>;
declare function install(app: App<any>): void;
export { ShikiMagicMove, ShikiMagicMovePrecompiled, ShikiMagicMoveRenderer, install };
+153
View File
@@ -0,0 +1,153 @@
import * as vue from 'vue';
import { PropType, App } from 'vue';
import { HighlighterCore } from 'shiki/core';
import { MagicMoveRenderOptions, MagicMoveDifferOptions, KeyedTokensInfo } from './types.js';
import 'diff-match-patch-es';
declare const ShikiMagicMove: vue.DefineComponent<vue.ExtractPropTypes<{
highlighter: {
type: PropType<HighlighterCore>;
required: true;
};
lang: {
type: StringConstructor;
required: true;
};
theme: {
type: StringConstructor;
required: true;
};
code: {
type: StringConstructor;
required: true;
};
options: {
type: PropType<MagicMoveRenderOptions & MagicMoveDifferOptions>;
default: () => {};
};
}>, () => vue.VNode<vue.RendererNode, vue.RendererElement, {
[key: string]: any;
}>, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, ("end" | "start")[], "end" | "start", vue.PublicProps, Readonly<vue.ExtractPropTypes<{
highlighter: {
type: PropType<HighlighterCore>;
required: true;
};
lang: {
type: StringConstructor;
required: true;
};
theme: {
type: StringConstructor;
required: true;
};
code: {
type: StringConstructor;
required: true;
};
options: {
type: PropType<MagicMoveRenderOptions & MagicMoveDifferOptions>;
default: () => {};
};
}>> & Readonly<{
onEnd?: ((...args: any[]) => any) | undefined;
onStart?: ((...args: any[]) => any) | undefined;
}>, {
options: MagicMoveRenderOptions & MagicMoveDifferOptions;
}, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>;
/**
* Component to render a compiled magic move step,
* Where the tokens can be generated on build time.
*/
declare const ShikiMagicMovePrecompiled: vue.DefineComponent<vue.ExtractPropTypes<{
steps: {
type: PropType<KeyedTokensInfo[]>;
required: true;
};
step: {
type: NumberConstructor;
default: number;
};
animate: {
type: BooleanConstructor;
default: boolean;
};
options: {
type: PropType<MagicMoveRenderOptions & MagicMoveDifferOptions>;
default: () => {};
};
}>, () => vue.VNode<vue.RendererNode, vue.RendererElement, {
[key: string]: any;
}>, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, ("end" | "start")[], "end" | "start", vue.PublicProps, Readonly<vue.ExtractPropTypes<{
steps: {
type: PropType<KeyedTokensInfo[]>;
required: true;
};
step: {
type: NumberConstructor;
default: number;
};
animate: {
type: BooleanConstructor;
default: boolean;
};
options: {
type: PropType<MagicMoveRenderOptions & MagicMoveDifferOptions>;
default: () => {};
};
}>> & Readonly<{
onEnd?: ((...args: any[]) => any) | undefined;
onStart?: ((...args: any[]) => any) | undefined;
}>, {
animate: boolean;
options: MagicMoveRenderOptions & MagicMoveDifferOptions;
step: number;
}, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>;
/**
* A wrapper component to `MagicMoveRenderer`
*/
declare const ShikiMagicMoveRenderer: vue.DefineComponent<vue.ExtractPropTypes<{
animate: {
type: BooleanConstructor;
default: boolean;
};
tokens: {
type: PropType<KeyedTokensInfo>;
required: true;
};
previous: {
type: PropType<KeyedTokensInfo>;
required: false;
};
options: {
type: PropType<MagicMoveRenderOptions>;
};
}>, () => vue.VNode<vue.RendererNode, vue.RendererElement, {
[key: string]: any;
}>, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, ("end" | "start")[], "end" | "start", vue.PublicProps, Readonly<vue.ExtractPropTypes<{
animate: {
type: BooleanConstructor;
default: boolean;
};
tokens: {
type: PropType<KeyedTokensInfo>;
required: true;
};
previous: {
type: PropType<KeyedTokensInfo>;
required: false;
};
options: {
type: PropType<MagicMoveRenderOptions>;
};
}>> & Readonly<{
onEnd?: ((...args: any[]) => any) | undefined;
onStart?: ((...args: any[]) => any) | undefined;
}>, {
animate: boolean;
}, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>;
declare function install(app: App<any>): void;
export { ShikiMagicMove, ShikiMagicMovePrecompiled, ShikiMagicMoveRenderer, install };
+172
View File
@@ -0,0 +1,172 @@
import { defineComponent, ref, onMounted, watch, nextTick, h, renderList, computed } from 'vue';
import { createMagicMoveMachine, codeToKeyedTokens, toKeyedTokens, syncTokenKeys } from './core.mjs';
import { MagicMoveRenderer } from './renderer.mjs';
import 'diff-match-patch-es';
import 'ohash';
/**
* A wrapper component to `MagicMoveRenderer`
*/
const ShikiMagicMoveRenderer = /* #__PURE__ */defineComponent({
name: 'ShikiMagicMoveRenderer',
props: {
animate: {
type: Boolean,
default: true
},
tokens: {
type: Object,
required: true
},
previous: {
type: Object,
required: false
},
options: {
type: Object
}
},
emits: ['end', 'start'],
setup(props, {
emit
}) {
const container = ref();
let isMounted = false;
onMounted(() => {
// Remove previous content
container.value.innerHTML = '';
isMounted = true;
const renderer = new MagicMoveRenderer(container.value);
watch(() => props.tokens, async tokens => {
Object.assign(renderer.options, props.options);
if (props.animate) {
if (props.previous) renderer.replace(props.previous);
await nextTick();
const process = renderer.render(tokens);
await nextTick();
emit('start');
await process;
emit('end');
} else {
renderer.replace(tokens);
}
}, {
immediate: true
});
});
return () => h('pre', {
ref: container,
class: 'shiki-magic-move-container'
},
// Render initial tokens for SSR
isMounted ? undefined : renderList(props.tokens.tokens, token => {
if (token.content === '\n') return h('br', {
key: token.key
});
return h('span', {
style: [{
color: token.color
}, token.htmlStyle],
class: ['shiki-magic-move-item', token.htmlClass],
key: token.key
}, token.content);
}));
}
});
const ShikiMagicMove = /* #__PURE__ */defineComponent({
name: 'ShikiMagicMove',
props: {
highlighter: {
type: Object,
required: true
},
lang: {
type: String,
required: true
},
theme: {
type: String,
required: true
},
code: {
type: String,
required: true
},
options: {
type: Object,
default: () => ({})
}
},
emits: ['start', 'end'],
setup(props, {
emit
}) {
const machine = createMagicMoveMachine(code => codeToKeyedTokens(props.highlighter, code, {
lang: props.lang,
theme: props.theme
}, props.options.lineNumbers), props.options);
const result = computed(() => machine.commit(props.code));
return () => h(ShikiMagicMoveRenderer, {
tokens: result.value.current,
options: props.options,
previous: result.value.previous,
onStart: () => emit('start'),
onEnd: () => emit('end')
});
}
});
/**
* Component to render a compiled magic move step,
* Where the tokens can be generated on build time.
*/
const ShikiMagicMovePrecompiled = /* #__PURE__ */defineComponent({
name: 'ShikiMagicMovePrecompiled',
props: {
steps: {
type: Array,
required: true
},
step: {
type: Number,
default: 0
},
animate: {
type: Boolean,
default: true
},
options: {
type: Object,
default: () => ({})
}
},
emits: ['start', 'end'],
setup(props, {
emit
}) {
const EMPTY = toKeyedTokens('', []);
let previous = EMPTY;
const result = computed(() => {
const res = syncTokenKeys(previous, props.steps[Math.min(props.step, props.steps.length - 1)], props.options);
previous = res.to;
return res;
});
return () => h(ShikiMagicMoveRenderer, {
tokens: result.value.to,
previous: result.value.from,
options: props.options,
animate: props.animate,
onStart: () => emit('start'),
onEnd: () => emit('end')
});
}
});
function install(app) {
app.component('ShikiMagicMove', ShikiMagicMove);
app.component('ShikiMagicMovePrecompiled', ShikiMagicMovePrecompiled);
app.component('ShikiMagicMoveRenderer', ShikiMagicMoveRenderer);
}
export { ShikiMagicMove, ShikiMagicMovePrecompiled, ShikiMagicMoveRenderer, install };
+99
View File
@@ -0,0 +1,99 @@
import { HighlighterCore } from 'shiki/core';
import { KeyedTokensInfo, MagicMoveRenderOptions, MagicMoveDifferOptions } from './types.mjs';
import 'diff-match-patch-es';
declare class ShikiMagicMoveRenderer extends HTMLElement {
private _animated;
private _tokens;
private _previous?;
private _options?;
private _class;
get animated(): boolean;
set animated(value: boolean);
get tokens(): KeyedTokensInfo;
set tokens(value: KeyedTokensInfo);
get previous(): KeyedTokensInfo | undefined;
set previous(value: KeyedTokensInfo);
get options(): MagicMoveRenderOptions | undefined;
set options(value: MagicMoveRenderOptions | undefined);
get class(): string;
set class(value: string);
private container?;
private renderer?;
private batchUpdate;
private hasUpdated;
constructor();
connectedCallback(): void;
propertyChangedCallback(): void;
transform(): Promise<void>;
}
declare global {
interface HTMLElementTagNameMap {
'shiki-magic-move-renderer': ShikiMagicMoveRenderer;
}
}
declare class ShikiMagicMove extends HTMLElement {
private _highlighter;
private _lang;
private _theme;
private _code;
private _class?;
private _options?;
get highlighter(): HighlighterCore;
set highlighter(value: HighlighterCore);
get lang(): string;
set lang(value: string);
get theme(): string;
set theme(value: string);
get code(): string;
set code(value: string);
get class(): string | undefined;
set class(value: string | undefined);
get options(): (MagicMoveRenderOptions & MagicMoveDifferOptions) | undefined;
set options(value: MagicMoveRenderOptions & MagicMoveDifferOptions | undefined);
private machine?;
private renderer?;
private result?;
private batchUpdate;
private hasUpdated;
constructor();
connectedCallback(): void;
propertyChangedCallback(): void;
updateRenderer(): void;
}
declare global {
interface HTMLElementTagNameMap {
'shiki-magic-move': ShikiMagicMove;
}
}
declare class ShikiMagicMovePrecompiled extends HTMLElement {
private _steps;
private _step;
private _animated;
private _options?;
get steps(): KeyedTokensInfo[];
set steps(value: KeyedTokensInfo[]);
get step(): number;
set step(value: number);
get animated(): boolean;
set animated(value: boolean);
get options(): MagicMoveRenderOptions & MagicMoveDifferOptions | undefined;
set options(value: MagicMoveRenderOptions & MagicMoveDifferOptions);
private renderer?;
private previous;
private batchUpdate;
private hasUpdated;
constructor();
connectedCallback(): void;
propertyChangedCallback(): void;
updateRenderer(): void;
}
declare global {
interface HTMLElementTagNameMap {
'shiki-magic-move-precompiled': ShikiMagicMovePrecompiled;
}
}
export { ShikiMagicMove, ShikiMagicMovePrecompiled, ShikiMagicMoveRenderer };
+99
View File
@@ -0,0 +1,99 @@
import { HighlighterCore } from 'shiki/core';
import { KeyedTokensInfo, MagicMoveRenderOptions, MagicMoveDifferOptions } from './types.js';
import 'diff-match-patch-es';
declare class ShikiMagicMoveRenderer extends HTMLElement {
private _animated;
private _tokens;
private _previous?;
private _options?;
private _class;
get animated(): boolean;
set animated(value: boolean);
get tokens(): KeyedTokensInfo;
set tokens(value: KeyedTokensInfo);
get previous(): KeyedTokensInfo | undefined;
set previous(value: KeyedTokensInfo);
get options(): MagicMoveRenderOptions | undefined;
set options(value: MagicMoveRenderOptions | undefined);
get class(): string;
set class(value: string);
private container?;
private renderer?;
private batchUpdate;
private hasUpdated;
constructor();
connectedCallback(): void;
propertyChangedCallback(): void;
transform(): Promise<void>;
}
declare global {
interface HTMLElementTagNameMap {
'shiki-magic-move-renderer': ShikiMagicMoveRenderer;
}
}
declare class ShikiMagicMove extends HTMLElement {
private _highlighter;
private _lang;
private _theme;
private _code;
private _class?;
private _options?;
get highlighter(): HighlighterCore;
set highlighter(value: HighlighterCore);
get lang(): string;
set lang(value: string);
get theme(): string;
set theme(value: string);
get code(): string;
set code(value: string);
get class(): string | undefined;
set class(value: string | undefined);
get options(): (MagicMoveRenderOptions & MagicMoveDifferOptions) | undefined;
set options(value: MagicMoveRenderOptions & MagicMoveDifferOptions | undefined);
private machine?;
private renderer?;
private result?;
private batchUpdate;
private hasUpdated;
constructor();
connectedCallback(): void;
propertyChangedCallback(): void;
updateRenderer(): void;
}
declare global {
interface HTMLElementTagNameMap {
'shiki-magic-move': ShikiMagicMove;
}
}
declare class ShikiMagicMovePrecompiled extends HTMLElement {
private _steps;
private _step;
private _animated;
private _options?;
get steps(): KeyedTokensInfo[];
set steps(value: KeyedTokensInfo[]);
get step(): number;
set step(value: number);
get animated(): boolean;
set animated(value: boolean);
get options(): MagicMoveRenderOptions & MagicMoveDifferOptions | undefined;
set options(value: MagicMoveRenderOptions & MagicMoveDifferOptions);
private renderer?;
private previous;
private batchUpdate;
private hasUpdated;
constructor();
connectedCallback(): void;
propertyChangedCallback(): void;
updateRenderer(): void;
}
declare global {
interface HTMLElementTagNameMap {
'shiki-magic-move-precompiled': ShikiMagicMovePrecompiled;
}
}
export { ShikiMagicMove, ShikiMagicMovePrecompiled, ShikiMagicMoveRenderer };
+240
View File
@@ -0,0 +1,240 @@
import { createMagicMoveMachine, codeToKeyedTokens, toKeyedTokens, syncTokenKeys } from './core.mjs';
import { MagicMoveRenderer } from './renderer.mjs';
import 'diff-match-patch-es';
import 'ohash';
class ShikiMagicMoveRenderer extends HTMLElement {
_animated = true;
_class = '';
get animated() {
return this._animated;
}
set animated(value) {
this._animated = value;
this.propertyChangedCallback();
}
get tokens() {
return this._tokens;
}
set tokens(value) {
this._tokens = value;
this.propertyChangedCallback();
}
get previous() {
return this._previous;
}
set previous(value) {
this._previous = value;
this.propertyChangedCallback();
}
get options() {
return this._options;
}
set options(value) {
this._options = value;
if (this.renderer) Object.assign(this.renderer.options, this.options);
this.propertyChangedCallback();
}
get class() {
return this._class;
}
set class(value) {
this._class = value;
this.propertyChangedCallback();
}
batchUpdate = false;
hasUpdated = false;
constructor() {
super();
}
connectedCallback() {
this.container = document.createElement('pre');
this.container.classList.add('shiki-magic-move-container');
this.container.className += ` ${this.class}`;
this.renderer = new MagicMoveRenderer(this.container);
this.renderer.render(this.tokens);
this.appendChild(this.container);
}
propertyChangedCallback() {
if (!this.batchUpdate) {
this.batchUpdate = true;
setTimeout(() => {
this.batchUpdate = false;
if (!this.hasUpdated) {
this.hasUpdated = true;
return;
}
if (this.renderer) this.transform();
}, 0);
}
}
async transform() {
if (!this.renderer) return;
if (!this.animated) return;
if (this.previous && this.previous === this.tokens) return;
if (this.previous) {
this.renderer.replace(this.previous);
this.dispatchEvent(new CustomEvent('onStart'));
await this.renderer?.render(this.tokens);
this.dispatchEvent(new CustomEvent('onEnd'));
} else {
this.renderer.replace(this.tokens);
}
}
}
customElements.define('shiki-magic-move-renderer', ShikiMagicMoveRenderer);
class ShikiMagicMove extends HTMLElement {
get highlighter() {
return this._highlighter;
}
set highlighter(value) {
this._highlighter = value;
this.propertyChangedCallback();
}
get lang() {
return this._lang;
}
set lang(value) {
this._lang = value;
this.propertyChangedCallback();
}
get theme() {
return this._theme;
}
set theme(value) {
this._theme = value;
this.propertyChangedCallback();
}
get code() {
return this._code;
}
set code(value) {
this._code = value;
this.propertyChangedCallback();
}
get class() {
return this._class;
}
set class(value) {
this._class = value;
this.propertyChangedCallback();
}
get options() {
return this._options;
}
set options(value) {
this._options = value;
this.propertyChangedCallback();
}
batchUpdate = false;
hasUpdated = false;
constructor() {
super();
}
connectedCallback() {
this.renderer = document.createElement('shiki-magic-move-renderer');
this.renderer.addEventListener('onStart', () => this.dispatchEvent(new CustomEvent('onStart')));
this.renderer.addEventListener('onEnd', () => this.dispatchEvent(new CustomEvent('onEnd')));
this.machine = createMagicMoveMachine(code => codeToKeyedTokens(this.highlighter, code, {
lang: this.lang,
theme: this.theme
}, this.options?.lineNumbers), this.options);
this.updateRenderer();
this.appendChild(this.renderer);
}
propertyChangedCallback() {
if (!this.batchUpdate) {
this.batchUpdate = true;
setTimeout(() => {
this.batchUpdate = false;
if (!this.hasUpdated) {
this.hasUpdated = true;
return;
}
this.updateRenderer();
}, 0);
}
}
updateRenderer() {
if (this.machine && this.renderer) {
this.result = this.machine.commit(this.code, this.options);
this.renderer.tokens = this.result.current;
this.renderer.previous = this.result.previous;
this.renderer.options = this.options;
this.renderer.class = this.class ?? '';
}
}
}
customElements.define('shiki-magic-move', ShikiMagicMove);
class ShikiMagicMovePrecompiled extends HTMLElement {
_steps = [];
_step = 0;
_animated = true;
get steps() {
return this._steps;
}
set steps(value) {
this._steps = value;
this.propertyChangedCallback();
}
get step() {
return this._step;
}
set step(value) {
this._step = value;
this.propertyChangedCallback();
}
get animated() {
return this._animated;
}
set animated(value) {
this._animated = value;
this.propertyChangedCallback();
}
get options() {
return this._options;
}
set options(value) {
this._options = value;
this.propertyChangedCallback();
}
previous = toKeyedTokens('', []);
batchUpdate = false;
hasUpdated = false;
constructor() {
super();
}
connectedCallback() {
this.renderer = document.createElement('shiki-magic-move-renderer');
this.renderer.addEventListener('onStart', () => this.dispatchEvent(new CustomEvent('onStart')));
this.renderer.addEventListener('onEnd', () => this.dispatchEvent(new CustomEvent('onEnd')));
this.updateRenderer();
this.appendChild(this.renderer);
}
propertyChangedCallback() {
if (!this.batchUpdate) {
this.batchUpdate = true;
setTimeout(() => {
this.batchUpdate = false;
if (!this.hasUpdated) {
this.hasUpdated = true;
return;
}
this.updateRenderer();
}, 0);
}
}
updateRenderer() {
const result = syncTokenKeys(this.previous, this.steps[Math.min(this.step, this.steps.length - 1)], this.options);
this.previous = result.to;
this.renderer.tokens = result.to;
this.renderer.previous = result.from;
this.renderer.options = this.options;
this.renderer.animated = this.animated;
}
}
customElements.define('shiki-magic-move-precompiled', ShikiMagicMovePrecompiled);
export { ShikiMagicMove, ShikiMagicMovePrecompiled, ShikiMagicMoveRenderer };