Skip to content

Commit

Permalink
feat(2d): code bounding box helpers (#948)
Browse files Browse the repository at this point in the history
  • Loading branch information
aarthificial committed Feb 10, 2024
1 parent 044c9ac commit 0ffd56f
Show file tree
Hide file tree
Showing 3 changed files with 227 additions and 40 deletions.
68 changes: 48 additions & 20 deletions packages/2d/src/lib/code/CodeCursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,24 @@ import {
unwrap,
Vector2,
} from '@motion-canvas/core';
import {Code, DrawHooks} from '../components';
import {Code} from '../components';
import {CodeFragment, parseCodeFragment} from './CodeFragment';
import {CodeHighlighter} from './CodeHighlighter';
import {CodeMetrics} from './CodeMetrics';
import {CodePoint, CodeRange} from './CodeRange';
import {CodeScope, isCodeScope} from './CodeScope';
import {isPointInCodeSelection} from './CodeSelection';

export interface CodeFragmentDrawingInfo {
text: string;
position: Vector2;
characterSize: Vector2;
cursor: Vector2;
fill: string;
time: number;
alpha: number;
}

/**
* A stateful class for recursively traversing a code scope.
*
Expand All @@ -35,7 +45,9 @@ export class CodeCursor {
private selection: CodeRange[] = [];
private selectionProgress: number | null = null;
private globalProgress: number[] = [];
private drawHooks = {} as DrawHooks;
private fragmentDrawingInfo: CodeFragmentDrawingInfo[] = [];
private fontHeight = 0;
private verticalOffset = 0;

public constructor(private readonly node: Code) {}

Expand All @@ -45,8 +57,12 @@ export class CodeCursor {
* @param context - The context used to measure and draw the code.
*/
public setupMeasure(context: CanvasRenderingContext2D) {
const metrics = context.measureText('X');
this.monoWidth = metrics.width;
this.fontHeight =
metrics.fontBoundingBoxDescent + metrics.fontBoundingBoxAscent;
this.verticalOffset = metrics.fontBoundingBoxAscent;
this.context = context;
this.monoWidth = context.measureText('X').width;
this.lineHeight = parseFloat(this.node.styles.lineHeight);
this.cursor = new Vector2();
this.beforeCursor = new Vector2();
Expand All @@ -65,7 +81,7 @@ export class CodeCursor {
this.highlighter = this.node.highlighter();
this.selection = this.node.selection();
this.selectionProgress = this.node.selectionProgress();
this.drawHooks = this.node.drawHooks();
this.fragmentDrawingInfo = [];
this.globalProgress = [];
}

Expand Down Expand Up @@ -129,7 +145,18 @@ export class CodeCursor {
public getSize() {
return {
x: this.maxWidth * this.monoWidth,
y: this.cursor.y * this.lineHeight,
y: this.cursor.y * this.lineHeight + this.verticalOffset,
};
}

/**
* Get the drawing information created by the cursor.
*/
public getDrawingInfo() {
return {
fragments: this.fragmentDrawingInfo,
verticalOffset: this.verticalOffset,
fontHeight: this.fontHeight,
};
}

Expand Down Expand Up @@ -216,8 +243,6 @@ export class CodeCursor {

const code = progress < 0.5 ? fragment.before : fragment.after;

this.context.save();
this.context.globalAlpha *= alpha;
let hasOffset = true;
let width = 0;
let stringLength = 0;
Expand All @@ -240,8 +265,6 @@ export class CodeCursor {
continue;
}

this.context.save();

const beforeHighlight =
this.caches &&
this.highlighter?.highlight(this.beforeIndex + i, this.caches.before);
Expand Down Expand Up @@ -330,24 +353,29 @@ export class CodeCursor {
);
}

this.drawHooks.token(
this.context,
char,
new Vector2(
const measure = this.context.measureText(char);
this.fragmentDrawingInfo.push({
text: char,
position: new Vector2(
(hasOffset ? offset.x + width : width) * this.monoWidth,
(offset.y + y) * this.lineHeight,
),
color,
cursor: new Vector2(
hasOffset ? this.beforeCursor.x + stringLength : stringLength,
this.beforeCursor.y + y,
),
alpha,
characterSize: new Vector2(
measure.width / char.length,
this.fontHeight,
),
fill: color,
time,
);
this.context.restore();
});

stringLength += char.length;
width += Math.round(
this.context.measureText(char).width / this.monoWidth,
);
width += Math.round(measure.width / this.monoWidth);
}
this.context.restore();
}

private calculateWidth(metrics: CodeMetrics, x = this.cursor.x): number {
Expand Down
160 changes: 151 additions & 9 deletions packages/2d/src/lib/components/Code.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
BBox,
createSignal,
ExperimentalError,
map,
Expand All @@ -13,14 +14,17 @@ import {
} from '@motion-canvas/core';
import {
CodeCursor,
CodeFragmentDrawingInfo,
CodeHighlighter,
CodePoint,
CodeRange,
CodeSelection,
CodeSignal,
codeSignal,
CodeSignalContext,
DefaultHighlightStyle,
findAllCodeRanges,
isPointInCodeSelection,
LezerHighlighter,
lines,
parseCodeSelection,
Expand Down Expand Up @@ -86,7 +90,6 @@ export interface CodeProps extends ShapeProps {
* {@inheritDoc Code.drawHooks}
*/
drawHooks?: SignalValue<DrawHooks>;
children?: never;
}

/**
Expand Down Expand Up @@ -359,7 +362,12 @@ export class Code extends Shape {
* @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]];
return (
findAllCodeRanges(this.parsed(), pattern, 1)[0] ?? [
[0, 0],
[0, 0],
]
);
}

/**
Expand All @@ -368,7 +376,123 @@ export class Code extends Shape {
* @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 (
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<DesiredLength> {
Expand All @@ -378,7 +502,7 @@ export class Code extends Shape {

context.save();
this.applyStyle(context);
context.font = this.styles.font;
this.applyText(context);
this.cursor.setupMeasure(context);
this.cursor.measureSize(code);
const size = this.cursor.getSize();
Expand All @@ -390,15 +514,33 @@ export class Code extends Shape {
protected override draw(context: CanvasRenderingContext2D): void {
this.requestFontUpdate();
this.applyStyle(context);
const code = this.code();
this.applyText(context);
const size = this.computedSize();
const drawingInfo = this.drawingInfo();

context.save();
context.translate(
-size.width / 2,
-size.height / 2 + drawingInfo.verticalOffset,
);

context.translate(-size.width / 2, -size.height / 2);
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();

this.drawChildren(context);
}

protected override applyText(context: CanvasRenderingContext2D) {
super.applyText(context);
context.font = this.styles.font;
context.textBaseline = 'top';

this.cursor.setupDraw(context);
this.cursor.drawScope(code);
}

protected override collectAsyncResources(): void {
Expand Down

0 comments on commit 0ffd56f

Please sign in to comment.