Code Animations
import ...
export default makeScene2D(function* (view) {
const code = createRef<Code>();
view.add(
<Code
ref={code}
fontSize={28}
fontFamily={'JetBrains Mono, monospace'}
offsetX={-1}
x={-400}
code={'const number = 7;'}
/>,
);
yield* waitFor(0.6);
yield* all(
code().code.replace(code().findFirstRange('number'), 'variable', 0.6),
code().code.prepend(0.6)`function example() {\n `,
code().code.append(0.6)`\n}`,
);
yield* waitFor(0.6);
yield* code().selection(code().findFirstRange('variable'), 0.6);
yield* waitFor(0.6);
yield* all(
code().code('const number = 7;', 0.6),
code().selection(DEFAULT, 0.6),
);
});
The Code
node is used for displaying code snippets.
It supports syntax highlighting and a handful of different methods for animating
the code.
Parsing and Highlighting
First things first, if you just copy any of the snippets in this tutorial you'll
notice that the displayed code has a uniform color. The default highlighter uses
Lezer
to parse and highlight the code but to
do that it needs the grammar for the language you're using. You can set that up
in your project configuration file.
For this tutorial, you should install the javascript
grammar:
npm i @lezer/javascript
Then, in your project configuration, instantiate a new LezerHighlighter
using
the imported grammar, and set it as the default highlighter:
import {makeProject} from '@revideo/core';
import example from './scenes/example?scene';
import {Code, LezerHighlighter} from '@revideo/2d';
import {parser} from '@lezer/javascript';
Code.defaultHighlighter = new LezerHighlighter(parser);
export default makeProject({
scenes: [example],
});
Now all Code
nodes in your project will use @lezer/javascript
to parse and
highlight the snippets. If you want to use more than one language, check out the
Multiple Languages section.
Note that, by default, the JavaScript parser doesn't support JSX or TypeScript. You can enable support for these via [dialects][dialects]. The dialects available for a given parser are usually listed in the documentation of the grammar package.
Code.defaultHighlighter = new LezerHighlighter(
parser.configure({
// Provide a space-separated list of dialects to enable:
dialect: 'jsx ts',
}),
);
Defining Code
The code to display is set via the code
property. In the simplest case, you can just use a string:
import ...
export default makeScene2D(function* (view) {
view.add(
<Code
fontSize={28}
code={'const number = 7;'}
/>,
);
});
However, usually code snippets contain multiple lines of code. It's much more
convenient to use a template string for this (denoted using the backtick
character `
):
view.add(
<Code
fontSize={28}
code={`\
function example() {
const number = 7;
}
`}
/>,
);
Notice two things here:
The code snippet ignores the indentation of the template string itself. The template string preserves all whitespace characters, so any additional spaces or tabs at the beginning of each line would be included in the snippet.
The backslash character (
\
) at the very beginning is used to escape the first newline character. This lets the snippet start on a new line without actually including an empty line at the beginning. Without the slash, the equivalent code would have to be written as:view.add(
<Code
fontSize={28}
code={`function example() {
const number = 7;
}
`}
/>,
);
Template strings allow you to easily include variables in your code snippets
with the ${}
syntax. In the example below, ${name}
is replaced with the
value of the name
variable (which is number
in this case):
const name = 'number';
view.add(
<Code
fontSize={28}
code={`\
function example() {
const ${name} = 7;
}
`}
/>,
);
Any valid JavaScript expression inside the ${}
syntax will be included in the
code snippet:
const isRed = true;
view.add(
<Code
fontSize={28}
code={`\
function example() {
const color = '${isRed ? 'red' : 'blue'}';
}
`}
/>,
);
Using Signals
If you try to use signals inside the ${}
syntax, you'll notice that they don't
work as expected. Invoking a signal inside a template string uses its current
value and then never updates the snippet again, even if the signal changes:
import {makeScene2D, Code} from '@revideo/2d';
import {waitFor} from '@revideo/core';
export default makeScene2D(function* (view) {
const nameSignal = Code.createSignal('number');
view.add(
<Code
fontSize={28}
code={`const ${nameSignal()} = 7;`}
/>,
);
yield* waitFor(1);
nameSignal('newValue');
// The code snippet still displays "number" instead of "newValue".
yield* waitFor(1);
});
Trying to pass the signal without invoking it is even worse. Since each signal is a function, it will be stringified and included in the snippet:
import ...
export default makeScene2D(function* (view) {
const nameSignal = Code.createSignal('number');
view.add(
<Code
fontSize={28}
code={`const ${nameSignal} = 7;`}
/>,
);
yield* waitFor(1);
nameSignal('newValue');
yield* waitFor(1);
});
This happens because template strings are parsed immediately when our code is
executed. To work around this, you can use a custom [tag function][tag-function]
called CODE
. It allows the Code
node to parse the
template string in a custom way and correctly support signals. It's really easy
to use, simply put the CODE
tag function before your template string:
import ...
export default makeScene2D(function* (view) {
const nameSignal = Code.createSignal('number');
view.add(
<Code
fontSize={28}
// Note the CODE tag function here:
code={CODE`const ${nameSignal} = 7;`}
/>,
);
yield* waitFor(1);
nameSignal('newValue');
// Now the code snippet is updated accordingly.
yield* waitFor(1);
});
The value returned by CODE
can itself be nested in other template strings:
const implementation = CODE`\
console.log('Hello!');
return 7;`;
const method = CODE`\
greet() {
${implementation}
}`;
const klass = CODE`\
class Example {
${method}
}
`;
view.add(<Code code={klass} />);
// class Example {
// greet() {
// console.log('Hello!');
// return 7;
// }
// }
You might have noticed that these examples used a specialized type of signal
created using Code.createSignal()
.
While the generic createSignal()
would work
fine in these simple examples, the specialized signal will shine once you start
animating your code snippets.
Animating Code
The Code
node comes with a few different techniques for animating the code
depending on the level of control you need.
Diffing
The default method for animating code is diffing. It's used whenever you tween
the code
property:
import ...
export default makeScene2D(function* (view) {
const code = createRef<Code>();
view.add(
<Code
ref={code}
fontSize={28}
offsetX={-1}
x={-400}
code={`\
function example() {
const number = 9;
}`}
/>,
);
yield* code().code('const nine = 9;', 0.6).wait(0.6).back(0.6).wait(0.6);
});
This method uses the patience diff algorithm to determine the differences between the old and new code snippets. It then animates the changes accordingly.
append
and prepend
For cases where you want to add some code at the beginning or end of the
snippet, you can use the append
and
prepend
methods. They can either
modify the code immediately or animate the changes over time:
import ...
export default makeScene2D(function* (view) {
const code = createRef<Code>();
view.add(
<Code
ref={code}
fontSize={28}
offsetX={-1}
x={-400}
/>,
);
// append immediately
code().code.append(`const one = 1;`);
// animate using the signal signature
yield* code().code.append('\nconst two = 2;', 0.6);
// animate using the template tag signature
yield* code().code.append(0.6)`
const three = 3;`;
// prepend works analogically
yield* code().code.prepend('// example\n', 0.6);
yield* waitFor(0.6);
});
insert
, replace
, and remove
For more granular control over the changes, you can use
insert
,
replace
, and
remove
to modify the code at specific
points. Check out Code Ranges for more information on how to
specify points in your code snippets.
import ...
export default makeScene2D(function* (view) {
const code = createRef<Code>();
view.add(
<Code
ref={code}
fontSize={28}
offsetX={-1}
x={-400}
code={`\
function example() {
console.log('Hello!');
}`}
/>,
);
// insert code at line 2, column 0
yield* code().code.insert([2, 0], ' return 7;\n', 0.6);
// replace the word "Hello!" with "Goodbye!"
yield* code().code.replace(word(1, 15, 6), 'Goodbye!', 0.6);
// remove line 2
yield* code().code.remove(lines(2), 0.6);
// animate multiple changes at the same time
yield* all(
code().code.replace(word(0, 9, 7), 'greet', 0.6),
code().code.replace(word(1, 15, 8), 'Hello!', 0.6),
);
yield* waitFor(0.6);
});
edit
The edit
method offers a different way
of defining code transitions. It's used together with the
replace
, insert
, and
remove
helper functions that are inserted into the
template string. They let you specify the changes in a more visual way, without
having to know the exact positions in the code:
import ...
export default makeScene2D(function* (view) {
const code = createRef<Code>();
view.add(
<Code
ref={code}
fontSize={28}
offsetX={-1}
x={-400}
/>,
);
yield* code().code.edit(0.6)`\
function example() {
${insert(`// This is a comment
`)}console.log("${replace('Hello!', 'Goodbye!')}");
${remove(` return 7;
`)}}`;
yield* waitFor(0.6);
});
Signals
Notice that all the methods used above are not invoked on the Code
node but
rather on its code
property. It may seem unnecessarily verbose but there's a
good reason for it: the code
property is a specialized code signal, just like
the ones created by
Code.createSignal()
. This means that
all the animation methods are also available on your own signals:
import ...
export default makeScene2D(function* (view) {
const message = Code.createSignal(`Hello, world!`);
const body = Code.createSignal(CODE`console.log('${message}');`);
view.add(
<Code
fontSize={28}
offsetX={-1}
x={-400}
code={CODE`\
function hello() {
${body}
}`}
/>,
);
yield* waitFor(0.3);
yield* all(
message('Goodbye, world!', 0.6),
body.append(0.6)`\n return 7;`,
);
yield* waitFor(0.3);
});
Code signals can also be nested in the template strings passed to the animation methods:
import {makeScene2D, Code, CODE} from '@revideo/2d';
import {createRef, waitFor} from '@revideo/core';
export default makeScene2D(function* (view) {
const code = createRef<Code>();
view.add(
<Code
ref={code}
fontSize={28}
offsetX={-1}
x={-400}
code={'// example'}
/>,
);
const body = Code.createSignal(CODE`console.log('Hello!');`);
yield* waitFor(0.3);
// Notice how the CODE tag is not used here because
// "append" already supports nested signals:
yield* code().code.append(0.6)`
function hello() {
${body}
}`;
// The "body" signal remains reactive after the append animation:
yield* body(`console.log('Goodbye!');`, 0.6);
yield* waitFor(0.3);
});
Code Ranges
A CodeRange
is used to specify a continuous span of
characters using line and column numbers. It can be used for editing the code,
visually selecting a part of it, or querying the positions and sizes of
characters.
Code ranges have the following structure:
// prettier-ignore
[[startLine, startColumn], [endLine, endColumn]];
For example, to select the first three characters of the second line, you would use the following range:
// prettier-ignore
[[1, 0], [1, 3]];
Keep in mind that both lines and columns are zero-based. Additionally, you
should think of columns as being located on the left side of the characters,
meaning that if you want to include the character at column n
you should use
n + 1
as the end column.
For convenience, the word
and
lines
helper functions are provided to create some of
the common types of ranges:
// a range starting at line 1, column 3,
// spanning 3 characters:
word(1, 3, 3);
// a range starting at line 1, column 3,
// spanning until the end of the line:
word(1, 3);
// a range containing lines from 1 to 3 (inclusive):
lines(1, 3);
// a range containing line 2
lines(2);
Once you create a Code
node, you can use its
findFirstRange
,
findAllRanges
, and
findLastRange
methods to find the
ranges that contain a specific string or match the given regular expression:
import {makeScene2D, Code} from '@revideo/2d';
import {createRef, waitFor} from '@revideo/core';
export default makeScene2D(function* (view) {
const code = createRef<Code>();
view.add(
<Code
ref={code}
fontSize={28}
offsetX={-1}
x={-400}
code={`\
function example() {
console.log('Hello!');
}`}
/>,
);
yield* code().code.replace(
// find the range of "example" and replace it with "greet"
code().findFirstRange('example'),
'greet',
0.6,
);
yield* waitFor(0.6);
});
Code Selection
The selection
property can be used
to visually distinguish a part of the code snippet. The selection is specified
using an individual code range
or an array of ranges:
import ...
export default makeScene2D(function* (view) {
const code = createRef<Code>();
view.add(
<Code
ref={code}
fontSize={28}
offsetX={-1}
x={-400}
code={`\
function hello() {
console.log('Hello');
}`}
/>,
);
// select all instances of "hello" (case-insensitive)
yield* code().selection(code().findAllRanges(/hello/gi), 0.6);
yield* waitFor(0.3);
// select line 1
yield* code().selection(lines(1), 0.6);
yield* waitFor(0.3);
// reset the selection
yield* code().selection(DEFAULT, 0.6);
yield* waitFor(0.3);
});
Querying Positions and Sizes
getPointBBox
and
getSelectionBBox
can be used to
retrieve the position and size of a specific character or a range of characters,
respectively. The returned value is a bounding box
in
the local space of the Code
node.
The following example uses getSelectionBBox
to draw a rectangle around the
word log
:
import ...
export default makeScene2D(function* (view) {
const code = createRef<Code>();
view.add(
<Code
ref={code}
fontSize={28}
offsetX={-1}
x={-400}
code={`\
function hello() {
console.log('Hello');
}`}
/>,
);
const range = createSignal(() => {
const range = code().findFirstRange('log');
const bboxes = code().getSelectionBBox(range);
// "getSelectionBBox" returns an array of bboxes,
// one for each line in the range. You can just
// use the first one for this example.
const first = bboxes[0];
return first.expand([4, 8]);
});
code().add(
<Rect
offset={-1}
position={range().position}
size={range().size}
lineWidth={4}
stroke={'white'}
radius={8}
/>,
);
});
Custom Themes
LezerHighlighter
uses CodeMirror's
HighlightStyle
to assign colors to
specific code tokens. By default, the
DefaultHighlightStyle
is used. You can
specify your own style by passing it as the second argument to the
LezerHighlighter
constructor:
import {Code, LezerHighlighter} from '@revideo/2d';
import {HighlightStyle} from '@codemirror/language';
import {tags} from '@lezer/highlight';
import {parser} from '@lezer/javascript';
const MyStyle = HighlightStyle.define([
{tag: tags.keyword, color: 'red'},
{tag: tags.function(tags.variableName), color: 'yellow'},
{tag: tags.number, color: 'blue'},
{tag: tags.string, color: 'green'},
// ...
]);
Code.defaultHighlighter = new LezerHighlighter(parser, MyStyle);
Multiple Languages
You can configure highlighters on a per-node basis using the
highlighter
property. This will
override the default highlighter set in the project configuration file:
import {Code, LezerHighlighter} from '@revideo/2d';
import {parser} from '@lezer/rust';
const RustHighlighter = new LezerHighlighter(parser);
// ...
view.add(
<Code
// this node uses the default parser
offsetX={-1}
x={-400}
code={`
function hello() {
console.log('Hello!');
}
`}
/>,
);
view.add(
<Code
// this node uses the Rust parser
highlighter={RustHighlighter}
offsetX={1}
x={400}
code={`
fn hello() {
println!("Hello!");
}
`}
/>,
);
It can be useful to create a custom component for the languages you often use.
You can use the withDefaults
helper function to
quickly extend any node with your own defaults:
import {Code, LezerHighlighter, withDefaults} from '@revideo/2d';
import {parser} from '@lezer/rust';
const RustHighlighter = new LezerHighlighter(parser);
export const RustCode = withDefaults(Code, {
highlighter: RustHighlighter,
});
import {RustCode} from '../nodes/RustCode';
// ...
view.add(
<RustCode
code={`
fn hello() {
println!("Hello!");
}
`}
/>,
);