import type { SerializedVector2, Signal, SignalValue, SimpleSignal, ThreadGenerator, TimingFunction, Vector2, } from '@revideo/core'; import { BBox, createSignal, experimentalLog, map, unwrap, useLogger, useScene, } from '@revideo/core'; import type { CodeFragmentDrawingInfo, CodeHighlighter, CodePoint, CodeRange, CodeSelection, CodeSignal, PossibleCodeScope, PossibleCodeSelection, } from '../code'; import { CodeCursor, codeSignal, CodeSignalContext, findAllCodeRanges, isPointInCodeSelection, lines, parseCodeSelection, resolveScope, } from '../code'; import {computed, initial, nodeName, parser, signal} from '../decorators'; import type {DesiredLength} from '../partials'; import type {ShapeProps} from './Shape'; import {Shape} from './Shape'; export interface DrawTokenHook { ( ctx: CanvasRenderingContext2D, text: string, position: Vector2, color: string, selection: number, ): void; } /** * Describes custom drawing logic used by the Code node. */ export interface DrawHooks { /** * Custom drawing logic for individual code tokens. * * @example * ```ts * token(ctx, text, position, color, selection) { * const blur = map(3, 0, selection); * const alpha = map(0.5, 1, selection); * ctx.globalAlpha *= alpha; * ctx.filter = `blur(${blur}px)`; * ctx.fillStyle = color; * ctx.fillText(text, position.x, position.y); * } * ``` */ token: DrawTokenHook; } export interface CodeProps extends ShapeProps { /** * {@inheritDoc Code.highlighter} */ highlighter?: SignalValue; /** * {@inheritDoc Code.code} */ code?: SignalValue; /** * {@inheritDoc Code.selection} */ selection?: SignalValue; /** * {@inheritDoc Code.drawHooks} */ drawHooks?: SignalValue; } /** * A node for displaying and animating code. * * @experimental * * @preview * ```tsx editor * import {parser} from '@lezer/javascript'; * import {Code, LezerHighlighter, makeScene2D} from '@revideo/2d'; * import {createRef} from '@revideo/core'; * * export default makeScene2D(function* (view) { * LezerHighlighter.registerParser(parser); * const code = createRef(); * * view.add( * , * ); * * yield* code() * .code( * `\ * function hello() { * console.warn('Hello World'); * }`, * 1, * ) * .wait(0.5) * .back(1) * .wait(0.5); * }); * ``` */ @nodeName('CodeBlock') export class Code extends Shape { /** * Create a standalone code signal. * * @param initial - The initial code. * @param highlighter - Custom highlighter to use. */ public static createSignal( initial: PossibleCodeScope, highlighter?: SignalValue, ): CodeSignal { return new CodeSignalContext( initial, undefined, highlighter, ).toSignal(); } public static defaultHighlighter: CodeHighlighter | null = null; /** * The code highlighter to use for this code node. * * @remarks * Defaults to a shared {@link code.LezerHighlighter}. */ @initial(() => Code.defaultHighlighter) @signal() public declare readonly highlighter: SimpleSignal< CodeHighlighter | null, this >; /** * The code to display. */ @codeSignal() public declare readonly code: CodeSignal; /** * Custom drawing logic for the code. * * @remarks * Check out {@link DrawHooks} for available render hooks. * * @example * Make the unselected code blurry and transparent: * ```tsx * * ``` */ @initial({ token(ctx, text, position, color, selection) { ctx.fillStyle = color; ctx.globalAlpha *= map(0.2, 1, selection); ctx.fillText(text, position.x, position.y); }, }) @signal() public declare readonly drawHooks: SimpleSignal; protected setDrawHooks(value: DrawHooks) { if ( !useScene().experimentalFeatures && value !== this.drawHooks.context.getInitial() ) { useLogger().log({ ...experimentalLog(`Code uses experimental draw hooks.`), inspect: this.key, }); } else { this.drawHooks.context.setter(value); } } /** * The currently selected code range. * * @remarks * Either a single {@link code.CodeRange} or an array of them * describing which parts of the code should be visually emphasized. * * You can use {@link code.word} and * {@link code.lines} to quickly create ranges. * * @example * The following will select the word "console" in the code. * Both lines and columns are 0-based. So it will select a 7-character-long * (`7`) word in the second line (`1`) starting at the third character (`2`). * ```tsx * { * console.log('Hello'); * }`} * // ... * /> * ``` */ @initial(lines(0, Infinity)) @parser(parseCodeSelection) @signal() public declare readonly selection: Signal< PossibleCodeSelection, CodeSelection, this >; public oldSelection: CodeSelection | null = null; public selectionProgress = createSignal(null); protected *tweenSelection( value: CodeRange[], duration: number, timingFunction: TimingFunction, ): ThreadGenerator { this.oldSelection = this.selection(); this.selection(value); this.selectionProgress(0); yield* this.selectionProgress(1, duration, timingFunction); this.selectionProgress(null); this.oldSelection = null; } /** * Get the currently displayed code as a string. */ @computed() public parsed(): string { return resolveScope(this.code(), scope => unwrap(scope.progress) > 0.5); } @computed() public highlighterCache() { const highlighter = this.highlighter(); if (!highlighter || !highlighter.initialize()) return null; const code = this.code(); const before = resolveScope(code, false); const after = resolveScope(code, true); return { before: highlighter.prepare(before), after: highlighter.prepare(after), }; } private cursorCache: CodeCursor | undefined; private get cursor() { this.cursorCache ??= new CodeCursor(this); return this.cursorCache; } public constructor(props: CodeProps) { super({ fontFamily: 'monospace', ...props, }); } /** * Create a child code signal. * * @param initial - The initial code. */ public createSignal(initial: PossibleCodeScope): CodeSignal { return new CodeSignalContext( initial, this, this.highlighter, ).toSignal(); } /** * Find all code ranges that match the given pattern. * * @param pattern - Either a string or a regular expression to match. */ public findAllRanges(pattern: string | RegExp): CodeRange[] { return findAllCodeRanges(this.parsed(), pattern); } /** * Find the first code range that matches the given pattern. * * @param pattern - Either a string or a regular expression to match. */ public findFirstRange(pattern: string | RegExp): CodeRange { return ( findAllCodeRanges(this.parsed(), pattern, 1)[0] ?? [ [0, 0], [0, 0], ] ); } /** * Find the last code range that matches the given pattern. * * @param pattern - Either a string or a regular expression to match. */ public findLastRange(pattern: string | RegExp): CodeRange { return ( findAllCodeRanges(this.parsed(), pattern).at(-1) ?? [ [0, 0], [0, 0], ] ); } /** * Return the bounding box of the given point (character) in the code. * * @remarks * The returned bound box is in local space of the `Code` node. * * @param point - The point to get the bounding box for. */ public getPointBbox(point: CodePoint): BBox { const [line, column] = point; const drawingInfo = this.drawingInfo(); let match: CodeFragmentDrawingInfo | undefined; for (const info of drawingInfo.fragments) { if (info.cursor.y < line) { match = info; continue; } if (info.cursor.y === line && info.cursor.x < column) { match = info; continue; } break; } if (!match) return new BBox(); const size = this.computedSize(); return new BBox( match.position .sub(size.scale(0.5)) .addX(match.characterSize.x * (column - match.cursor.x)), match.characterSize, ); } /** * Return bounding boxes of all characters in the selection. * * @remarks * The returned bound boxes are in local space of the `Code` node. * Each line of code has a separate bounding box. * * @param selection - The selection to get the bounding boxes for. */ public getSelectionBbox(selection: PossibleCodeSelection): BBox[] { const size = this.computedSize(); const range = parseCodeSelection(selection); const drawingInfo = this.drawingInfo(); const bboxes: BBox[] = []; let current: BBox | null = null; let line = 0; let column = 0; for (const info of drawingInfo.fragments) { if (info.cursor.y !== line) { line = info.cursor.y; if (current) { bboxes.push(current); current = null; } } column = info.cursor.x; for (let i = 0; i < info.text.length; i++) { if (isPointInCodeSelection([line, column], range)) { const bbox = new BBox( info.position .sub(size.scale(0.5)) .addX(info.characterSize.x * (column - info.cursor.x)), info.characterSize, ); if (!current) { current = bbox; } else { current = current.union(bbox); } } else if (current) { bboxes.push(current); current = null; } column++; } } if (current) { bboxes.push(current); } return bboxes; } @computed() protected drawingInfo() { this.requestFontUpdate(); const context = this.cacheCanvas(); const code = this.code(); context.save(); this.applyStyle(context); this.applyText(context); this.cursor.setupDraw(context); this.cursor.drawScope(code); const info = this.cursor.getDrawingInfo(); context.restore(); return info; } protected override desiredSize(): SerializedVector2 { this.requestFontUpdate(); const context = this.cacheCanvas(); const code = this.code(); context.save(); this.applyStyle(context); this.applyText(context); this.cursor.setupMeasure(context); this.cursor.measureSize(code); const size = this.cursor.getSize(); context.restore(); return size; } protected override async draw( context: CanvasRenderingContext2D, ): Promise { this.requestFontUpdate(); this.applyStyle(context); this.applyText(context); const size = this.computedSize(); const drawingInfo = this.drawingInfo(); context.save(); context.translate( -size.width / 2, -size.height / 2 + drawingInfo.verticalOffset, ); const drawHooks = this.drawHooks(); for (const info of drawingInfo.fragments) { context.save(); context.globalAlpha *= info.alpha; drawHooks.token(context, info.text, info.position, info.fill, info.time); context.restore(); } context.restore(); await this.drawChildren(context); } protected override applyText(context: CanvasRenderingContext2D) { super.applyText(context); context.font = this.styles.font; context.textBaseline = 'top'; if ('letterSpacing' in context) { context.letterSpacing = this.styles.letterSpacing; } } protected override collectAsyncResources(): void { super.collectAsyncResources(); this.highlighter()?.initialize(); } }